Fuege Screen-Loeschung, Meta-Update, Datei-Persistenz und Lifecycle-Test hinzu

- DELETE /api/v1/screens/{screenId}/status loescht einzelne Screen-Eintraege
- /api/v1/meta listet jetzt 5 Tools inkl. screen-status-delete und diagnostic_ui-Pfade
- filePlayerStatusStore persistiert den Status-Store atomar in einer JSON-Datei
- MORZ_INFOBOARD_STATUS_STORE_PATH aktiviert die Datei-Persistenz (leer = In-Memory)
- Integration-Test deckt den vollstaendigen Lifecycle: POST -> list -> HTML -> JSON -> DELETE -> 404 ab
- DEVELOPMENT.md beschreibt End-to-End-Entwicklungstest und neue Env-Variable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-22 20:34:37 +01:00
parent ea90af1403
commit 56635554c7
12 changed files with 493 additions and 7 deletions

View file

@ -138,14 +138,21 @@ Standard:
Konfigurierbar ueber: Konfigurierbar ueber:
- `MORZ_INFOBOARD_HTTP_ADDR` - `MORZ_INFOBOARD_HTTP_ADDR` HTTP-Adresse (Standard: `:8080`)
- `MORZ_INFOBOARD_STATUS_STORE_PATH` Pfad zur JSON-Datei fuer persistenten Status-Store; leer lassen fuer reinen In-Memory-Betrieb
Beispiel: Beispiele:
```bash ```bash
MORZ_INFOBOARD_HTTP_ADDR=:18080 go run ./cmd/api MORZ_INFOBOARD_HTTP_ADDR=:18080 go run ./cmd/api
``` ```
```bash
MORZ_INFOBOARD_HTTP_ADDR=:8080 \
MORZ_INFOBOARD_STATUS_STORE_PATH=/tmp/screen-status.json \
go run ./cmd/api
```
### Agent lokal starten ### Agent lokal starten
```bash ```bash
@ -190,7 +197,45 @@ go run ./cmd/agent
4. Agent: danach MQTT-spezifische Reachability und feinere Connectivity-Schwellenlogik aufsetzen 4. Agent: danach MQTT-spezifische Reachability und feinere Connectivity-Schwellenlogik aufsetzen
5. Danach die Netzwerk-, Sync- und Kommandopfade schrittweise produktionsnah ausbauen 5. Danach die Netzwerk-, Sync- und Kommandopfade schrittweise produktionsnah ausbauen
Ergaenzt seit dem ersten Geruest: ## End-to-End-Entwicklungstest (Backend + Agent)
Backend und Agent koennen lokal gegeneinander laufen. Reihenfolge:
1. Backend starten (Terminal 1):
```bash
cd server/backend
MORZ_INFOBOARD_STATUS_STORE_PATH=/tmp/screen-status.json go run ./cmd/api
```
2. Agent starten (Terminal 2):
```bash
cd player/agent
MORZ_INFOBOARD_SCREEN_ID=info01-dev \
MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080 \
go run ./cmd/agent
```
3. Status pruefen:
```bash
# JSON-Uebersicht
curl http://127.0.0.1:8080/api/v1/screens/status
# HTML-Diagnoseseite
open http://127.0.0.1:8080/status
# Einzelner Screen
curl http://127.0.0.1:8080/api/v1/screens/info01-dev/status
# Screen loeschen
curl -X DELETE http://127.0.0.1:8080/api/v1/screens/info01-dev/status
```
Die Datei `/tmp/screen-status.json` enthaelt nach dem ersten Heartbeat den persistierten Status-Store.
## Ergaenzt seit dem ersten Geruest:
- `message_wall`-Resolver im Backend - `message_wall`-Resolver im Backend
- Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt - Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt
@ -201,6 +246,10 @@ Ergaenzt seit dem ersten Geruest:
- Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab - Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab
- Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab - Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab
- Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten, inklusive Auto-Refresh, leichten Filtern und JSON-Drill-down - Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten, inklusive Auto-Refresh, leichten Filtern und JSON-Drill-down
- Backend unterstuetzt `q=` (Screen-ID-Substring), `derived_state=`, `server_connectivity=`, `stale=`, `updated_since=`, `limit=` als Query-Filter
- Backend leitet `derived_state` (online / degraded / offline) aus `stale`, `server_connectivity` und `status` ab
- Backend erlaubt das Loeschen einzelner Screen-Eintraege via `DELETE /api/v1/screens/{screenId}/status`
- Backend persistiert den Status-Store optional in einer JSON-Datei (`MORZ_INFOBOARD_STATUS_STORE_PATH`)
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides - dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown - strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
- erster periodischer HTTP-Status-Reporter im Agent - erster periodischer HTTP-Status-Reporter im Agent

View file

@ -16,11 +16,16 @@ type App struct {
func New() (*App, error) { func New() (*App, error) {
cfg := config.Load() cfg := config.Load()
store, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
if err != nil {
return nil, err
}
return &App{ return &App{
Config: cfg, Config: cfg,
server: &http.Server{ server: &http.Server{
Addr: cfg.HTTPAddress, Addr: cfg.HTTPAddress,
Handler: httpapi.NewRouter(httpapi.NewPlayerStatusStore()), Handler: httpapi.NewRouter(store),
}, },
}, nil }, nil
} }

View file

@ -4,11 +4,13 @@ import "os"
type Config struct { type Config struct {
HTTPAddress string HTTPAddress string
StatusStorePath string
} }
func Load() Config { func Load() Config {
return Config{ return Config{
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"), HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
} }
} }

View file

@ -0,0 +1,126 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestPlayerStatusLifecycle covers the full lifecycle of a screen status entry:
// ingest → list → HTML detail → JSON detail → delete → verify gone.
func TestPlayerStatusLifecycle(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
}
router := NewRouter(store)
// 1. POST /api/v1/player/status ingest a status report
body := `{
"screen_id": "lifecycle-screen",
"ts": "2026-03-22T16:09:30Z",
"status": "running",
"server_connectivity": "online",
"server_url": "http://127.0.0.1:8080",
"mqtt_broker": "tcp://127.0.0.1:1883",
"heartbeat_every_seconds": 30,
"started_at": "2026-03-22T16:00:00Z",
"last_heartbeat_at": "2026-03-22T16:09:30Z"
}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("POST status: got %d, want %d body: %s", got, want, w.Body.String())
}
// 2. GET /api/v1/screens/status appears in list with derived_state=online
req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("GET list: got %d, want %d", got, want)
}
var list struct {
Summary struct{ Total int } `json:"summary"`
Screens []playerStatusRecord `json:"screens"`
}
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
t.Fatalf("GET list Unmarshal: %v", err)
}
if got, want := list.Summary.Total, 1; got != want {
t.Fatalf("summary.total = %d, want %d", got, want)
}
if got, want := list.Screens[0].ScreenID, "lifecycle-screen"; got != want {
t.Fatalf("Screens[0].ScreenID = %q, want %q", got, want)
}
if got, want := list.Screens[0].DerivedState, "online"; got != want {
t.Fatalf("Screens[0].DerivedState = %q, want %q", got, want)
}
// 3. GET /status/lifecycle-screen HTML detail page contains key fields
req = httptest.NewRequest(http.MethodGet, "/status/lifecycle-screen", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("GET HTML detail: got %d, want %d", got, want)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", ct)
}
htmlBody := w.Body.String()
for _, want := range []string{"lifecycle-screen", "online", "running"} {
if !strings.Contains(htmlBody, want) {
t.Fatalf("HTML detail page missing %q", want)
}
}
// 4. GET /api/v1/screens/lifecycle-screen/status JSON detail
req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/lifecycle-screen/status", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("GET JSON detail: got %d, want %d", got, want)
}
var detail playerStatusRecord
if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil {
t.Fatalf("GET JSON detail Unmarshal: %v", err)
}
if got, want := detail.ScreenID, "lifecycle-screen"; got != want {
t.Fatalf("detail.ScreenID = %q, want %q", got, want)
}
if got, want := detail.ServerURL, "http://127.0.0.1:8080"; got != want {
t.Fatalf("detail.ServerURL = %q, want %q", got, want)
}
// 5. DELETE /api/v1/screens/lifecycle-screen/status remove the record
req = httptest.NewRequest(http.MethodDelete, "/api/v1/screens/lifecycle-screen/status", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("DELETE: got %d, want %d body: %s", got, want, w.Body.String())
}
// 6. GET /api/v1/screens/lifecycle-screen/status must be 404 now
req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/lifecycle-screen/status", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusNotFound; got != want {
t.Fatalf("GET after delete: got %d, want %d", got, want)
}
// 7. GET /api/v1/screens/status list must be empty again
req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
t.Fatalf("GET list after delete Unmarshal: %v", err)
}
if got, want := list.Summary.Total, 0; got != want {
t.Fatalf("summary.total after delete = %d, want %d", got, want)
}
}

View file

@ -30,6 +30,15 @@ func handleMeta(w http.ResponseWriter, _ *http.Request) {
"method": http.MethodPost, "method": http.MethodPost,
"path": "/api/v1/player/status", "path": "/api/v1/player/status",
}, },
{
"name": "screen-status-delete",
"method": http.MethodDelete,
"path": "/api/v1/screens/{screenId}/status",
},
},
"diagnostic_ui": map[string]string{
"screen_list": "/status",
"screen_detail": "/status/{screenId}",
}, },
}, },
}) })

View file

@ -143,6 +143,21 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
} }
} }
func handleDeletePlayerStatus(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := strings.TrimSpace(r.PathValue("screenId"))
if screenID == "" {
writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil)
return
}
if !store.Delete(screenID) {
writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
}
func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc { func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
overview, err := buildScreenStatusOverview(store, r.URL.Query()) overview, err := buildScreenStatusOverview(store, r.URL.Query())

View file

@ -26,6 +26,7 @@ type playerStatusStore interface {
Get(screenID string) (playerStatusRecord, bool) Get(screenID string) (playerStatusRecord, bool)
List() []playerStatusRecord List() []playerStatusRecord
Now() time.Time Now() time.Time
Delete(screenID string) bool
} }
type inMemoryPlayerStatusStore struct { type inMemoryPlayerStatusStore struct {
@ -74,6 +75,16 @@ func (s *inMemoryPlayerStatusStore) List() []playerStatusRecord {
return records return records
} }
func (s *inMemoryPlayerStatusStore) Delete(screenID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.records[screenID]
if ok {
delete(s.records, screenID)
}
return ok
}
func (s *inMemoryPlayerStatusStore) Now() time.Time { func (s *inMemoryPlayerStatusStore) Now() time.Time {
if s.now == nil { if s.now == nil {
return time.Now() return time.Now()

View file

@ -0,0 +1,89 @@
package httpapi
import (
"encoding/json"
"os"
"path/filepath"
)
// filePlayerStatusStore wraps inMemoryPlayerStatusStore and persists the
// records to a JSON file on every mutation. Reads are served from memory.
type filePlayerStatusStore struct {
*inMemoryPlayerStatusStore
path string
}
func newFilePlayerStatusStore(path string) (*filePlayerStatusStore, error) {
s := &filePlayerStatusStore{
inMemoryPlayerStatusStore: newInMemoryPlayerStatusStore(),
path: path,
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
// NewStoreFromConfig returns a playerStatusStore. If path is non-empty a
// file-backed store is used; otherwise an in-memory store is returned.
func NewStoreFromConfig(path string) (playerStatusStore, error) {
if path == "" {
return newInMemoryPlayerStatusStore(), nil
}
return newFilePlayerStatusStore(path)
}
func (s *filePlayerStatusStore) Save(record playerStatusRecord) {
s.inMemoryPlayerStatusStore.Save(record)
_ = s.persist()
}
func (s *filePlayerStatusStore) Delete(screenID string) bool {
ok := s.inMemoryPlayerStatusStore.Delete(screenID)
if ok {
_ = s.persist()
}
return ok
}
func (s *filePlayerStatusStore) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var records []playerStatusRecord
if err := json.Unmarshal(data, &records); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range records {
s.records[r.ScreenID] = r
}
return nil
}
func (s *filePlayerStatusStore) persist() error {
s.mu.RLock()
records := make([]playerStatusRecord, 0, len(s.records))
for _, r := range s.records {
records = append(records, r)
}
s.mu.RUnlock()
data, err := json.Marshal(records)
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, filepath.Clean(s.path))
}

View file

@ -0,0 +1,122 @@
package httpapi
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestFilePlayerStatusStorePersistsOnSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "status.json")
s, err := newFilePlayerStatusStore(path)
if err != nil {
t.Fatalf("newFilePlayerStatusStore() error = %v", err)
}
s.Save(playerStatusRecord{ScreenID: "screen-a", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var records []playerStatusRecord
if err := json.Unmarshal(data, &records); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := len(records), 1; got != want {
t.Fatalf("len(records) = %d, want %d", got, want)
}
if got, want := records[0].ScreenID, "screen-a"; got != want {
t.Fatalf("ScreenID = %q, want %q", got, want)
}
}
func TestFilePlayerStatusStorePersistsOnDelete(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "status.json")
s, err := newFilePlayerStatusStore(path)
if err != nil {
t.Fatalf("newFilePlayerStatusStore() error = %v", err)
}
s.Save(playerStatusRecord{ScreenID: "screen-a", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
s.Save(playerStatusRecord{ScreenID: "screen-b", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
if ok := s.Delete("screen-a"); !ok {
t.Fatal("Delete() = false, want true")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var records []playerStatusRecord
if err := json.Unmarshal(data, &records); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := len(records), 1; got != want {
t.Fatalf("len(records) = %d, want %d", got, want)
}
if got, want := records[0].ScreenID, "screen-b"; got != want {
t.Fatalf("ScreenID = %q, want %q", got, want)
}
}
func TestFilePlayerStatusStoreLoadsExistingData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "status.json")
existing := []playerStatusRecord{
{ScreenID: "screen-x", Timestamp: "2026-03-22T16:00:00Z", Status: "running"},
}
data, _ := json.Marshal(existing)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
s, err := newFilePlayerStatusStore(path)
if err != nil {
t.Fatalf("newFilePlayerStatusStore() error = %v", err)
}
rec, ok := s.Get("screen-x")
if !ok {
t.Fatal("Get() = false, want true")
}
if got, want := rec.ScreenID, "screen-x"; got != want {
t.Fatalf("ScreenID = %q, want %q", got, want)
}
}
func TestFilePlayerStatusStoreMissingFileIsOK(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nonexistent.json")
s, err := newFilePlayerStatusStore(path)
if err != nil {
t.Fatalf("newFilePlayerStatusStore() with missing file error = %v", err)
}
if got, want := len(s.List()), 0; got != want {
t.Fatalf("len(List()) = %d, want %d", got, want)
}
}
func TestNewStoreFromConfigInMemoryWhenNoPath(t *testing.T) {
store, err := NewStoreFromConfig("")
if err != nil {
t.Fatalf("NewStoreFromConfig(\"\") error = %v", err)
}
store.Save(playerStatusRecord{ScreenID: "s1", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
if _, ok := store.Get("s1"); !ok {
t.Fatal("Get() = false after Save()")
}
}

View file

@ -738,3 +738,44 @@ func TestHandleListLatestPlayerStatusesFiltersByUpdatedSince(t *testing.T) {
t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want) t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want)
} }
} }
func TestHandleDeletePlayerStatusOK(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.Save(playerStatusRecord{ScreenID: "info01-dev", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/screens/info01-dev/status", nil)
req.SetPathValue("screenId", "info01-dev")
w := httptest.NewRecorder()
handleDeletePlayerStatus(store)(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
var response map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := response["status"], "deleted"; got != want {
t.Fatalf("status = %q, want %q", got, want)
}
if _, ok := store.Get("info01-dev"); ok {
t.Fatal("record still present after delete")
}
}
func TestHandleDeletePlayerStatusNotFound(t *testing.T) {
store := newInMemoryPlayerStatusStore()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/screens/missing/status", nil)
req.SetPathValue("screenId", "missing")
w := httptest.NewRecorder()
handleDeletePlayerStatus(store)(w, req)
if got, want := w.Code, http.StatusNotFound; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}

View file

@ -34,6 +34,7 @@ func NewRouter(store playerStatusStore) http.Handler {
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store)) mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store))
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(store)) mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(store))
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store)) mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store))
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(store))
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall) mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)

View file

@ -104,6 +104,10 @@ func TestRouterMeta(t *testing.T) {
Method string `json:"method"` Method string `json:"method"`
Path string `json:"path"` Path string `json:"path"`
} `json:"tools"` } `json:"tools"`
DiagnosticUI struct {
ScreenList string `json:"screen_list"`
ScreenDetail string `json:"screen_detail"`
} `json:"diagnostic_ui"`
} `json:"api"` } `json:"api"`
} }
@ -119,7 +123,7 @@ func TestRouterMeta(t *testing.T) {
t.Fatalf("api.health = %q, want %q", got, want) t.Fatalf("api.health = %q, want %q", got, want)
} }
if got, want := len(response.API.Tools), 4; got != want { if got, want := len(response.API.Tools), 5; got != want {
t.Fatalf("len(api.tools) = %d, want %d", got, want) t.Fatalf("len(api.tools) = %d, want %d", got, want)
} }
@ -138,6 +142,18 @@ func TestRouterMeta(t *testing.T) {
if got, want := response.API.Tools[3].Path, "/api/v1/player/status"; got != want { if got, want := response.API.Tools[3].Path, "/api/v1/player/status"; got != want {
t.Fatalf("api.tools[3].path = %q, want %q", got, want) t.Fatalf("api.tools[3].path = %q, want %q", got, want)
} }
if got, want := response.API.Tools[4].Path, "/api/v1/screens/{screenId}/status"; got != want {
t.Fatalf("api.tools[4].path = %q, want %q", got, want)
}
if got, want := response.API.DiagnosticUI.ScreenList, "/status"; got != want {
t.Fatalf("api.diagnostic_ui.screen_list = %q, want %q", got, want)
}
if got, want := response.API.DiagnosticUI.ScreenDetail, "/status/{screenId}"; got != want {
t.Fatalf("api.diagnostic_ui.screen_detail = %q, want %q", got, want)
}
} }
func TestRouterPlayerStatusRoute(t *testing.T) { func TestRouterPlayerStatusRoute(t *testing.T) {