fix
This commit is contained in:
+35
-10
@@ -11,12 +11,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"wh/comms"
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/store"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
"halcyon/comms"
|
||||
"halcyon/config"
|
||||
"halcyon/runner"
|
||||
"halcyon/store"
|
||||
"halcyon/vm"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -33,7 +33,9 @@ type Server struct {
|
||||
// POST /assemble assembly text -> program (base64), size
|
||||
// GET /ships -> your ships
|
||||
// PUT /ships/{id}/program raw bytecode (ship must be in inventory)
|
||||
// POST /ships/{id}/launch (enters the belt on the next run)
|
||||
// POST /ships/{id}/launch {"slot": n} (optional) (enters the belt on the next run; with a slot, that
|
||||
// slot's code is assembled and sealed in first)
|
||||
// GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples)
|
||||
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
|
||||
//
|
||||
@@ -49,6 +51,10 @@ func New(st *store.Store, cfg config.Config) *http.ServeMux {
|
||||
mux.Handle("GET /ships", s.auth(s.ships))
|
||||
mux.Handle("PUT /ships/{id}/program", s.auth(s.program))
|
||||
mux.Handle("POST /ships/{id}/launch", s.auth(s.launch))
|
||||
mux.Handle("GET /slots", s.auth(s.listSlots))
|
||||
mux.Handle("GET /slots/{n}", s.auth(s.getSlot))
|
||||
mux.Handle("PUT /slots/{n}", s.auth(s.putSlot))
|
||||
mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot))
|
||||
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink))
|
||||
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
|
||||
return mux
|
||||
@@ -148,9 +154,9 @@ func (s *Server) info(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"day": s.currentDay(r.Context()),
|
||||
"ticks_per_day": s.cfg.TicksPerDay,
|
||||
"tick_seconds": s.cfg.TickSeconds(),
|
||||
"cycles_per_tick": s.cfg.CyclesPerTick,
|
||||
"turns_per_day": s.cfg.TicksPerDay,
|
||||
"turn_seconds": s.cfg.TickSeconds(),
|
||||
"cycles_per_turn": s.cfg.CyclesPerTick,
|
||||
"program_bytes": s.cfg.ProgramBytes,
|
||||
"ram_bytes": s.cfg.RAMBytes,
|
||||
"comm_bytes": s.cfg.CommBytes,
|
||||
@@ -242,6 +248,25 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// An optional {"slot": n} chooses which code slot the ship flies. Without it
|
||||
// the ship launches with the program already sealed in it (PUT .../program).
|
||||
body, ok := readBody(w, r, 1<<10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(string(body)) != "" {
|
||||
var req struct {
|
||||
Slot *int `json:"slot"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, `body must be {"slot": <1-16>} or empty`)
|
||||
return
|
||||
}
|
||||
if req.Slot != nil {
|
||||
s.launchFromSlot(w, r, p, id, *req.Slot)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/runner"
|
||||
"wh/sim"
|
||||
"wh/store"
|
||||
"wh/vm"
|
||||
"halcyon/api"
|
||||
"halcyon/config"
|
||||
"halcyon/runner"
|
||||
"halcyon/sim"
|
||||
"halcyon/store"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
func TestEndToEnd(t *testing.T) {
|
||||
@@ -25,10 +25,10 @@ func TestEndToEnd(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.AsteroidCount = 20
|
||||
|
||||
// Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL
|
||||
// Set HALCYON_TEST_PG to a postgres:// URL to run this against PostgreSQL
|
||||
// (its public schema is wiped first).
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
if pg := os.Getenv("WH_TEST_PG"); pg != "" {
|
||||
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" {
|
||||
db, err := sql.Open("pgx", pg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+80
-11
@@ -2,26 +2,46 @@ package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"wh/api"
|
||||
"wh/config"
|
||||
"wh/store"
|
||||
"wh/web"
|
||||
"halcyon/api"
|
||||
"halcyon/config"
|
||||
"halcyon/store"
|
||||
"halcyon/web"
|
||||
)
|
||||
|
||||
// newSite starts the API with the web front end mounted, as cmd/server does.
|
||||
func newSite(t *testing.T) (*httptest.Server, config.Config) {
|
||||
func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) {
|
||||
srv, cfg, _ := newSiteStore(t, manualPDF)
|
||||
return srv, cfg
|
||||
}
|
||||
|
||||
// newSiteStore is newSite, also returning the database so tests can run a simulated day.
|
||||
func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Config, *store.Store) {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db"))
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" { // run against PostgreSQL, wiping its public schema first
|
||||
db, err := sql.Open("pgx", pg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
dsn = pg
|
||||
}
|
||||
st, err := store.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,10 +50,10 @@ func newSite(t *testing.T) (*httptest.Server, config.Config) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, cfg)
|
||||
mux.Handle("/", web.Handler())
|
||||
mux.Handle("/", web.Handler(manualPDF))
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, cfg
|
||||
return srv, cfg, st
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (*http.Response, string) {
|
||||
@@ -48,7 +68,7 @@ func get(t *testing.T, url string) (*http.Response, string) {
|
||||
}
|
||||
|
||||
func TestInfoAndAssemble(t *testing.T) {
|
||||
srv, cfg := newSite(t)
|
||||
srv, cfg := newSite(t, "")
|
||||
|
||||
_, body := get(t, srv.URL+"/info")
|
||||
var info struct {
|
||||
@@ -92,10 +112,10 @@ func TestInfoAndAssemble(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebAssets(t *testing.T) {
|
||||
srv, _ := newSite(t)
|
||||
srv, _ := newSite(t, "")
|
||||
for path, want := range map[string]string{
|
||||
"/": "<title>",
|
||||
"/manual.txt": "PROGRAMMER'S REFERENCE MANUAL",
|
||||
"/manual.md": "Programmer's Reference Manual",
|
||||
"/examples/echo.s": "ECHO",
|
||||
"/examples/index.json": `"pursue"`,
|
||||
} {
|
||||
@@ -108,8 +128,57 @@ func TestWebAssets(t *testing.T) {
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
|
||||
t.Error("missing CSP header")
|
||||
}
|
||||
// The PDF is rendered separately; until it exists /manual.pdf explains how.
|
||||
presp, pbody := get(t, srv.URL+"/manual.pdf")
|
||||
if presp.StatusCode != 404 || !strings.Contains(pbody, "render-manual.sh") {
|
||||
t.Errorf("/manual.pdf before any render: status %d: %s", presp.StatusCode, pbody)
|
||||
}
|
||||
// The API still wins over the catch-all.
|
||||
if resp, _ := get(t, srv.URL+"/market"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
t.Errorf("/market served as %q", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manual.pdf")
|
||||
srv, _ := newSite(t, path)
|
||||
|
||||
if resp, _ := get(t, srv.URL+"/manual.pdf"); resp.StatusCode != 404 {
|
||||
t.Fatalf("before the file exists: %d", resp.StatusCode)
|
||||
}
|
||||
write := func(content string) {
|
||||
// Like the renderer: write beside the target, then rename into place.
|
||||
if err := os.WriteFile(path+".tmp", []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(path+".tmp", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("%PDF-1.7 first render")
|
||||
resp, body := get(t, srv.URL+"/manual.pdf")
|
||||
if resp.StatusCode != 200 || body != "%PDF-1.7 first render" || resp.Header.Get("Content-Type") != "application/pdf" {
|
||||
t.Fatalf("first render: %d %q %q", resp.StatusCode, body, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != "no-cache" || resp.Header.Get("Content-Security-Policy") != "" {
|
||||
t.Errorf("headers: cache=%q csp=%q", resp.Header.Get("Cache-Control"), resp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
|
||||
// A re-render is served immediately, with no restart.
|
||||
write("%PDF-1.7 second render, longer")
|
||||
if _, body := get(t, srv.URL+"/manual.pdf"); body != "%PDF-1.7 second render, longer" {
|
||||
t.Fatalf("new render not picked up: %q", body)
|
||||
}
|
||||
|
||||
// Range requests work (PDF viewers rely on them).
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/manual.pdf", nil)
|
||||
req.Header.Set("Range", "bytes=0-4")
|
||||
rr, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rr.Body.Close()
|
||||
if part, _ := io.ReadAll(rr.Body); rr.StatusCode != 206 || string(part) != "%PDF-" {
|
||||
t.Fatalf("range: %d %q", rr.StatusCode, part)
|
||||
}
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"halcyon/comms"
|
||||
"halcyon/slots"
|
||||
"halcyon/store"
|
||||
"halcyon/vm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSourceBytes = 64 << 10
|
||||
maxSlotName = 40
|
||||
)
|
||||
|
||||
var (
|
||||
errBadSlot = errors.New("slot must be a number from 1 to 16")
|
||||
errEmptySlot = errors.New("that slot is empty")
|
||||
)
|
||||
|
||||
type slotView struct {
|
||||
Slot int `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Empty bool `json:"empty"`
|
||||
}
|
||||
|
||||
func slotNumber(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || !slots.Valid(n) {
|
||||
httpError(w, http.StatusBadRequest, errBadSlot.Error())
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// slotContent returns what is in slot n for this player. An empty slot has an
|
||||
// empty source. The examples in slots 1-3 are the same for everyone.
|
||||
func (s *Server) slotContent(r *http.Request, owner int64, n int) (slots.Slot, error) {
|
||||
if !slots.Valid(n) {
|
||||
return slots.Slot{}, errBadSlot
|
||||
}
|
||||
if b, ok := slots.Builtin(n); ok {
|
||||
return b, nil
|
||||
}
|
||||
cs, err := s.st.GetCodeSlot(r.Context(), owner, n)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return slots.Slot{Number: n}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return slots.Slot{}, err
|
||||
}
|
||||
return slots.Slot{Number: n, Name: cs.Name, Source: cs.Source}, nil
|
||||
}
|
||||
|
||||
// GET /slots: the sixteen slots, without their source.
|
||||
func (s *Server) listSlots(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
saved, err := s.st.ListCodeSlots(r.Context(), p.ID)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]slotView, 0, slots.Count)
|
||||
for n := 1; n <= slots.Count; n++ {
|
||||
if b, ok := slots.Builtin(n); ok {
|
||||
out = append(out, slotView{Slot: n, Name: b.Name, Builtin: true})
|
||||
continue
|
||||
}
|
||||
name, has := saved[n]
|
||||
out = append(out, slotView{Slot: n, Name: name, Empty: !has})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GET /slots/{n}: one slot with its source.
|
||||
func (s *Server) getSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
n, ok := slotNumber(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sl, err := s.slotContent(r, p.ID, n)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"slot": n, "name": sl.Name, "builtin": sl.Builtin, "empty": sl.Source == "" && sl.Name == "", "source": sl.Source,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /slots/{n}: save source in one of slots 4-16.
|
||||
func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
n, ok := slotNumber(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, builtin := slots.Builtin(n); builtin {
|
||||
httpError(w, http.StatusForbidden, "slots 1 to "+strconv.Itoa(slots.FirstUser-1)+" hold the examples and are read-only; save your copy in slot "+strconv.Itoa(slots.FirstUser)+" or higher")
|
||||
return
|
||||
}
|
||||
body, ok := readBody(w, r, maxSourceBytes+4<<10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, `body must be {"name": "...", "source": "..."}`)
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
switch {
|
||||
case req.Name == "" || len([]rune(req.Name)) > maxSlotName:
|
||||
httpError(w, http.StatusBadRequest, "name must be 1 to "+strconv.Itoa(maxSlotName)+" characters")
|
||||
return
|
||||
case len(req.Source) > maxSourceBytes:
|
||||
httpError(w, http.StatusRequestEntityTooLarge, "source is larger than "+strconv.Itoa(maxSourceBytes)+" bytes")
|
||||
return
|
||||
}
|
||||
if err := s.st.PutCodeSlot(r.Context(), p.ID, n, req.Name, req.Source, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name})
|
||||
}
|
||||
|
||||
// DELETE /slots/{n}: empty one of slots 4-16.
|
||||
func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
n, ok := slotNumber(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, builtin := slots.Builtin(n); builtin {
|
||||
httpError(w, http.StatusForbidden, "the example slots cannot be cleared")
|
||||
return
|
||||
}
|
||||
if err := s.st.DeleteCodeSlot(r.Context(), p.ID, n); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// launchFromSlot assembles the chosen slot and seals it into the ship as it launches.
|
||||
func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.Player, ship int64, n int) {
|
||||
sl, err := s.slotContent(r, p.ID, n)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(sl.Source) == "" {
|
||||
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" is empty")
|
||||
return
|
||||
}
|
||||
prog, err := vm.Assemble(sl.Source)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" does not assemble: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := comms.CheckProgram(prog, s.cfg.ProgramBytes); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+": "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.LaunchWithProgram(r.Context(), p.ID, ship, prog); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"halcyon/runner"
|
||||
)
|
||||
|
||||
// player is a registered account talking to the API.
|
||||
type player struct {
|
||||
t *testing.T
|
||||
base string
|
||||
key string
|
||||
ship int64
|
||||
}
|
||||
|
||||
func newPlayer(t *testing.T, base, name string) *player {
|
||||
t.Helper()
|
||||
resp, err := http.Post(base+"/register", "application/json", strings.NewReader(`{"name":"`+name+`"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var r struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ShipID int64 `json:"ship_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil || r.APIKey == "" {
|
||||
t.Fatalf("register %s: %v", name, err)
|
||||
}
|
||||
return &player{t: t, base: base, key: r.APIKey, ship: r.ShipID}
|
||||
}
|
||||
|
||||
// do sends a request and returns the body, failing unless the status is want.
|
||||
func (p *player) do(method, path string, body any, want int) []byte {
|
||||
p.t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
if s, ok := body.(string); ok {
|
||||
rdr = strings.NewReader(s)
|
||||
} else {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest(method, p.base+path, rdr)
|
||||
if p.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
p.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type slotRow struct {
|
||||
Slot int `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Empty bool `json:"empty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func (p *player) slots() []slotRow {
|
||||
var rows []slotRow
|
||||
json.Unmarshal(p.do("GET", "/slots", nil, 200), &rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
func (p *player) slot(n int) slotRow {
|
||||
var r slotRow
|
||||
json.Unmarshal(p.do("GET", "/slots/"+itoa(int64(n)), nil, 200), &r)
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *player) shipStatus() (status string, programBytes int) {
|
||||
var ships []struct {
|
||||
Status string `json:"status"`
|
||||
ProgramBytes int `json:"program_bytes"`
|
||||
}
|
||||
json.Unmarshal(p.do("GET", "/ships", nil, 200), &ships)
|
||||
return ships[0].Status, ships[0].ProgramBytes
|
||||
}
|
||||
|
||||
func TestSlotsListAndExamples(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
a := newPlayer(t, srv.URL, "ann")
|
||||
|
||||
rows := a.slots()
|
||||
if len(rows) != 16 {
|
||||
t.Fatalf("%d slots, want 16", len(rows))
|
||||
}
|
||||
for i, want := range []string{"ECHO", "TELEMETRY", "PURSUE"} {
|
||||
if r := rows[i]; r.Slot != i+1 || r.Name != want || !r.Builtin || r.Empty {
|
||||
t.Errorf("slot %d = %+v, want the %s example", i+1, r, want)
|
||||
}
|
||||
}
|
||||
for _, r := range rows[3:] {
|
||||
if r.Builtin || !r.Empty || r.Name != "" {
|
||||
t.Errorf("slot %d should start empty: %+v", r.Slot, r)
|
||||
}
|
||||
}
|
||||
if s := a.slot(2); !s.Builtin || !strings.Contains(s.Source, "TELEMETRY") {
|
||||
t.Errorf("slot 2 = %+v", s)
|
||||
}
|
||||
if s := a.slot(9); !s.Empty || s.Source != "" {
|
||||
t.Errorf("empty slot 9 = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotsSaveLoadClearAndIsolation(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
a, b := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
|
||||
|
||||
a.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": "ldi r0, 1\nhalt"}, 200)
|
||||
if s := a.slot(5); s.Name != "MINER" || s.Source != "ldi r0, 1\nhalt" || s.Empty || s.Builtin {
|
||||
t.Fatalf("saved slot = %+v", s)
|
||||
}
|
||||
if r := a.slots()[4]; r.Name != "MINER" || r.Empty {
|
||||
t.Fatalf("listing = %+v", r)
|
||||
}
|
||||
// Saving again replaces it, including the name.
|
||||
a.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200)
|
||||
if s := a.slot(5); s.Name != "MINER 2" || s.Source != "halt" {
|
||||
t.Fatalf("overwritten slot = %+v", s)
|
||||
}
|
||||
// Slots are private to their owner.
|
||||
if s := b.slot(5); !s.Empty {
|
||||
t.Fatalf("bob can see ann's slot: %+v", s)
|
||||
}
|
||||
|
||||
// The examples cannot be changed or cleared.
|
||||
for _, n := range []string{"1", "2", "3"} {
|
||||
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 403)
|
||||
a.do("DELETE", "/slots/"+n, nil, 403)
|
||||
}
|
||||
// Bad slot numbers and bodies.
|
||||
for _, n := range []string{"0", "17", "abc", "-1"} {
|
||||
a.do("GET", "/slots/"+n, nil, 400)
|
||||
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 400)
|
||||
}
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": " ", "source": "halt"}, 400)
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": strings.Repeat("x", 41), "source": "halt"}, 400)
|
||||
a.do("PUT", "/slots/6", "not json", 400)
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": "BIG", "source": strings.Repeat("x", 64<<10+1)}, 413)
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": "OK", "source": strings.Repeat("x", 64<<10)}, 200)
|
||||
|
||||
// Clearing.
|
||||
a.do("DELETE", "/slots/5", nil, 204)
|
||||
a.do("DELETE", "/slots/5", nil, 204) // clearing an empty slot is fine
|
||||
if s := a.slot(5); !s.Empty {
|
||||
t.Fatalf("cleared slot = %+v", s)
|
||||
}
|
||||
|
||||
// Everything needs a key.
|
||||
anon := &player{t: t, base: srv.URL}
|
||||
anon.do("GET", "/slots", nil, 401)
|
||||
anon.do("PUT", "/slots/5", map[string]string{"name": "X", "source": "halt"}, 401)
|
||||
}
|
||||
|
||||
func TestLaunchFromSlot(t *testing.T) {
|
||||
srv, cfg := newSite(t, "")
|
||||
launch := func(p *player, slot any, want int) []byte {
|
||||
return p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]any{"slot": slot}, want)
|
||||
}
|
||||
|
||||
// A saved slot: assembled, sealed into the ship, and launched in one step.
|
||||
a := newPlayer(t, srv.URL, "ann")
|
||||
a.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": "ldi r0, 1\nhalt"}, 200)
|
||||
var res struct {
|
||||
Slot int `json:"slot"`
|
||||
ProgramBytes int `json:"program_bytes"`
|
||||
}
|
||||
json.Unmarshal(launch(a, 6, 202), &res)
|
||||
if res.Slot != 6 || res.ProgramBytes != 8 {
|
||||
t.Fatalf("launch reply = %+v", res)
|
||||
}
|
||||
if st, n := a.shipStatus(); st != "launching" || n != 8 {
|
||||
t.Fatalf("ship is %s with %d program bytes", st, n)
|
||||
}
|
||||
launch(a, 6, 409) // already launching
|
||||
|
||||
// Things that must be refused, leaving the ship docked and its program untouched.
|
||||
b := newPlayer(t, srv.URL, "bob")
|
||||
b.do("PUT", "/slots/7", map[string]string{"name": "BROKEN", "source": "ldi r1, 5\nbogus r1"}, 200)
|
||||
b.do("PUT", "/slots/8", map[string]string{"name": "HUGE", "source": strings.Repeat("nop\n", cfg.ProgramBytes/4+1)}, 200)
|
||||
b.do("PUT", "/slots/10", map[string]string{"name": "BLANK", "source": " \n"}, 200)
|
||||
for name, c := range map[string]struct {
|
||||
slot any
|
||||
msg string
|
||||
}{
|
||||
"empty slot": {9, "empty"},
|
||||
"blank source": {10, "empty"},
|
||||
"does not compile": {7, "line 2"},
|
||||
"too large": {8, "limit"},
|
||||
"out of range": {99, "1 to 16"},
|
||||
"zero": {0, "1 to 16"},
|
||||
} {
|
||||
if body := launch(b, c.slot, 400); !strings.Contains(string(body), c.msg) {
|
||||
t.Errorf("%s: error %q should mention %q", name, body, c.msg)
|
||||
}
|
||||
}
|
||||
if st, n := b.shipStatus(); st != "inventory" || n != 0 {
|
||||
t.Fatalf("a refused launch changed the ship: %s, %d bytes", st, n)
|
||||
}
|
||||
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", "{not json", 400)
|
||||
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", nil, 409) // no slot and no sealed program: as before
|
||||
// Slots are per player: ann's saved slot 6 is empty for bob.
|
||||
launch(b, 6, 400)
|
||||
// Ann cannot launch bob's ship with her slot.
|
||||
a.do("POST", "/ships/"+itoa(b.ship)+"/launch", map[string]int{"slot": 6}, 404)
|
||||
|
||||
// An example slot works for anyone.
|
||||
launch(b, 2, 202)
|
||||
if st, n := b.shipStatus(); st != "launching" || n == 0 {
|
||||
t.Fatalf("after launching the telemetry example: %s, %d bytes", st, n)
|
||||
}
|
||||
}
|
||||
|
||||
// A ship launched from a slot really flies that slot's program.
|
||||
func TestSlotProgramRunsInTheBelt(t *testing.T) {
|
||||
srv, cfg, st := newSiteStore(t, "")
|
||||
cfg.AsteroidCount = 20
|
||||
p := newPlayer(t, srv.URL, "ann")
|
||||
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 2}, 202) // TELEMETRY
|
||||
|
||||
if _, err := runner.RunNextDay(context.Background(), st, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down := p.do("GET", "/ships/"+itoa(p.ship)+"/downlink", nil, 200)
|
||||
if tick := binary.LittleEndian.Uint32(down[0:4]); tick != uint32(cfg.TicksPerDay-1) {
|
||||
t.Fatalf("telemetry downlink reports tick %d, want the last tick %d", tick, cfg.TicksPerDay-1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user