diff --git a/ui/PLAN.md b/ui/PLAN.md index 434921a..b5f33c5 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1032,44 +1032,129 @@ Goal: assemble the in-game layout shell (header, sidebar, main area) with empty placeholder content for every view, so navigation works end-to-end before any data is wired. -Artifacts: +Decisions taken with the project owner during implementation: -- `ui/frontend/src/routes/games/[id]/+layout.svelte` shell layout with - responsive breakpoints (desktop / tablet / mobile) -- `ui/frontend/src/lib/header/` header component: race name, turn - counter (static placeholder `turn ?`), view dropdown / hamburger, - account menu -- `ui/frontend/src/lib/sidebar/` sidebar with three tabs (Calculator, - Inspector, Order), each tab content stubbed to `coming soon`; mobile - bottom-tab bar `[Map, Calc, Order, More]` with corresponding stub - panels -- `ui/frontend/src/lib/active-view/` view router supporting - `/games/:id/{map,table/:entity,report,battle/:battleId,mail, - designer/...}` with stub content per view -- topic doc `ui/docs/navigation.md` documenting the active-view - model, the state-preservation rule, and the transient map-overlay - concept (the back-stack mechanism itself is implemented in Phase 34 - when the first overlay user, ship-designer reach circles, ships) +1. **Routing — file-system based, no extra dispatcher.** The + "view router" called out in the original artifact list is + implemented as SvelteKit's file-system routes plus thin + `+page.svelte` wrappers that mount the matching + `lib/active-view/.svelte` stub. No separate dispatch + component lives in the codebase; each route file is a two-line + wrapper. +2. **Optional designer ID segments.** Both designer URLs ship as + `[[id]]` optional segments + (`designer/ship-class/[[classId]]/`, + `designer/science/[[scienceId]]/`) so Phase 18 / 21 can read + the param without a routing migration. Phase 10 stubs ignore + the param. +3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts + both the list URL (`/battle`) and a specific battle URL + (`/battle/`). Phase 27 keeps the optional segment and + switches behaviour based on presence. +4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 px + tablet sidebar slides in from a header-button click rather + than the IA section's swipe-from-right gesture. The structural + breakpoint switch satisfies Phase 10's acceptance criterion; + Phase 35 polish lands the swipe gesture. +5. **Mobile tool overlay — `mobileTool` state, gated by URL.** + The mobile bottom-tabs Calc / Order navigate to `/map` and + set a layout-owned `mobileTool` rune. The layout's derived + `effectiveTool` only honours the rune when the URL is `/map`, + so navigating to any other view via the More drawer or the + header view-menu naturally drops the overlay. The desktop + sidebar separately accepts a `?sidebar=calc|inspector|order` + URL param that seeds the initial tab on first mount, used by + later phases that want to land directly on a particular tool. +6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30 + each name their final implementation + (`order-tab.svelte`, `inspector-tab.svelte`, + `calculator-tab.svelte`). The Phase 10 stubs ship with those + names so later phases replace the content in place without + renaming. +7. **Race-name and turn-counter placeholders.** The header race + name is the static `race ?` string from i18n, mirroring the + spec's static `turn ?` placeholder. Phase 11 wires both from + `user.games.report` data through `lib/header/turn-counter.svelte`. +8. **Auth gate inherited.** The root `+layout.svelte` already + redirects `anonymous → /login`; the in-game shell needs no + extra guard. Phase 10 verified this by booting the e2e shell + spec via `__galaxyDebug.setDeviceSessionId` and observing the + post-`session.init` `authenticated` status. +9. **More drawer mirrors the view-menu.** The mobile bottom-tabs + "More" drawer renders the same seven destinations as the + header view-menu. The IA section's narrower More list (Mail, + Battle log, Tables, History, Settings, Logout) is the polish + target for Phase 35 once History exists; Phase 10 keeps a + single destination list to avoid drift. + +Artifacts (delivered): + +- `ui/frontend/src/routes/games/[id]/+layout.svelte` — chrome + layout (header, conditional sidebar, active-view slot, mobile + bottom-tabs, mobileTool gate, sidebarOpen toggle) +- `ui/frontend/src/routes/games/[id]/+layout.ts` — + `ssr=false; prerender=false;` mirroring the root SPA flags +- `ui/frontend/src/routes/games/[id]/+page.ts` — redirects + `/games/:id` → `/games/:id/map` +- `ui/frontend/src/routes/games/[id]/{map, table/[entity], report, + battle/[[battleId]], mail, designer/ship-class/[[classId]], + designer/science/[[scienceId]]}/+page.svelte` — thin route + wrappers that mount the matching active-view stub +- `ui/frontend/src/lib/header/{header, turn-counter, view-menu, + account-menu}.svelte` — header composition with race + placeholder, turn counter (static `?`), view-menu + (dropdown desktop / hamburger mobile), and account menu + (Settings / Sessions / Theme stub buttons; Language driven by + `i18n.setLocale`; Logout calls `session.signOut("user")`) +- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab, + inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab + sidebar with `inspector` default and `?sidebar=` URL seed; + mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a + More drawer duplicating the view-menu destinations +- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab` + and `MobileTool` types +- `ui/frontend/src/lib/active-view/{map, table, report, battle, + mail, designer-ship-class, designer-science}.svelte` — Phase 10 + stubs rendering localised titles plus `coming soon` copy with + stable testids that later phases replace +- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — full + `game.shell.*`, `game.view.*`, `game.sidebar.*`, + `game.bottom_tabs.*` catalogue +- Topic doc `ui/docs/navigation.md` +- Vitest: `tests/game-shell-{header,sidebar,stubs}.test.ts` +- Playwright: `tests/e2e/game-shell.spec.ts` (7 cases × 4 projects; + mobile-only and viewport-switch cases conditionally skipped on + non-matching projects) Dependencies: Phase 8. -Acceptance criteria: +Acceptance criteria (met): -- entering `/games/:id/map` from the lobby renders the shell with all - navigation chrome; -- header dropdown switches to every other view; mobile hamburger does - the same; +- entering `/games/:id/map` from the lobby renders the shell with + all navigation chrome; +- header dropdown switches to every other view; mobile hamburger + does the same; - sidebar tabs preserve their stub state across switches; - the responsive layout matches the breakpoint diagrams in - `Information Architecture and Navigation`. + `Information Architecture and Navigation` (with the swipe + gesture deferred to Phase 35). -Targeted tests: +Targeted tests (delivered): -- Vitest component tests for header navigation actions; -- Playwright e2e: visit every view stub via header dropdown, assert - empty state copy renders; -- multi-viewport Playwright run validating layout switches at the 768 - px and 1024 px breakpoints. +- Vitest component tests for the header (race / turn placeholders, + view-menu navigation to every IA destination, account-menu + Logout / Language wiring); +- Vitest component tests for the sidebar (default tab, switching, + empty-state copy, `?sidebar=` URL seed, close button); +- Vitest component tests for every active-view stub (title, + `coming soon` copy, table-entity prop, battle-id prop); +- Playwright e2e: visit every view stub via header dropdown and + via the mobile More drawer; sidebar tab choice survives + navigation across active views; mobile bottom-tabs toggle the + Calc / Order tool overlay; +- Playwright e2e: `setViewportSize`-driven viewport switch test + validates layout transitions at 768 px and 1024 px (sidebar + visibility, sidebar-toggle / bottom-tabs visibility). ## Phase 11. Map Wired to Live Game State diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md new file mode 100644 index 0000000..fede5f1 --- /dev/null +++ b/ui/docs/navigation.md @@ -0,0 +1,127 @@ +# In-game shell — navigation model + +This doc covers the chrome that wraps every in-game view: the +responsive layout shell, the active-view router built on SvelteKit's +file-system routes, the sidebar with three tools and its +state-preservation rule, and the mobile bottom-tabs. The user-facing +spec — view list, breakpoint diagrams, history-mode plans — lives in +[`../PLAN.md`](../PLAN.md), section +`Information Architecture and Navigation`. This doc is the source of +truth for how those rules are implemented. + +## Active-view model + +The client renders **one active view at a time**. Every active view is +a SvelteKit route under `routes/games/[id]/`; the route file is a +two-line wrapper that mounts the matching content component from +`src/lib/active-view/.svelte`. The "view router" mentioned in +the plan is the file system plus those wrappers — there is no +separate dispatch component. + +| URL | Active view component | Phase that fills it | +| ------------------------------------- | ---------------------------------------------- | ----------------------- | +| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 | +| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 | +| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 | +| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | +| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | +| `/games/:id/designer/ship-class/:id?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 / 18 | +| `/games/:id/designer/science/:id?` | `lib/active-view/designer-science.svelte` | Phase 21 | + +`/games/:id` (no trailing view) redirects to `/games/:id/map`. The +optional `:id?` segments on the designer routes match SvelteKit's +`[[id]]` syntax — they accept both the new-draft and editing URLs; +later phases read the param when wiring real content. + +The `entity` slug on the table route is kebab-case (`planets`, +`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`); the +table stub maps it to the matching `game.view.table.` i18n +key. + +## Sidebar tools and state preservation + +The desktop sidebar hosts three tools: + +| Tool | Component | Phase that fills it | +| ---------- | -------------------------------------- | -------------------- | +| Calculator | `lib/sidebar/calculator-tab.svelte` | Phase 30 | +| Inspector | `lib/sidebar/inspector-tab.svelte` | Phase 13 / 19 | +| Order | `lib/sidebar/order-tab.svelte` | Phase 12 / 14 | + +The sidebar's selected-tab state is a `$state` rune inside +`lib/sidebar/sidebar.svelte`. The component is mounted by the layout +at `routes/games/[id]/+layout.svelte`, and SvelteKit keeps that +layout instance alive while the user navigates between child routes +(`/games/:id/map` → `/games/:id/report` → …). The rune therefore +survives every active-view switch automatically, with no URL coupling +needed. + +A `?sidebar=calc|calculator|inspector|order` URL param is read once +on mount and seeds the initial tab. Later phases that want to land +the user on a particular tool (for example, Phase 14's first +end-to-end command flow) can set it on navigation. + +## Layout breakpoints + +Three discrete CSS modes matched to the IA section diagrams: + +- **≥ 1024 px (desktop)** — the sidebar sits beside the active view + and is always rendered. The header view-menu trigger uses the + dropdown icon (▾). Bottom-tabs and the tablet sidebar-toggle are + CSS-hidden. +- **768–1024 px (tablet)** — the sidebar collapses behind a click + toggle in the header right corner. Tapping the toggle slides the + sidebar in as a fixed overlay above the active view; a close + button on the sidebar dismisses it. The full swipe-from-right + gesture in the IA section is deferred to Phase 35 polish — the + click toggle satisfies the "layout switches at 768 px" acceptance + criterion on Phase 10. +- **< 768 px (mobile)** — the sidebar is hidden entirely and the + bottom-tabs row appears at the bottom of the viewport. The + view-menu trigger swaps to a hamburger icon (☰) that opens the + drop-down as a full-width drawer below the header. + +Inspector is intentionally unreachable on mobile in Phase 10. Per the +IA section the mobile inspector is a bottom-sheet raised by tapping a +map object, and that mechanism waits for Phase 13. + +## Mobile bottom-tabs and tool overlay + +The bottom-tabs row is `[Map, Calc, Order, More]`. Map navigates to +`/games/:id/map` and clears any tool overlay. Calc and Order navigate +to `/games/:id/map` too — but they also flip the layout's +`mobileTool` state to `calc` / `order`, which the layout uses to +swap the active-view slot for the Calculator / Order tool component. + +The tool overlay only applies when the URL is `/map`. Navigating to +any other view through the More drawer or the header view-menu makes +the layout's derived `effectiveTool` collapse back to `map`, so the +user always sees the URL's active view rather than a stale overlay. +The next time the user taps a Calc or Order bottom-tab, the +navigation re-routes them to `/map` and re-applies the overlay. + +The `More` button opens a drawer that mirrors the header view-menu +content. The IA section's narrower "More" list (Mail, Battle log, +Tables, History, Settings, Logout) is the polish target for Phase 35 +— Phase 10 keeps a single source of truth for destinations. + +## Transient map overlays + +Some views can push a transient overlay onto `/map` with a back +affordance — for example, the ship-class designer pushes a +range-preview overlay onto the map. The transient overlay clears +when the user navigates to any other view via the header or the +bottom-tabs. + +Phase 10 documents this concept but does not implement the +back-stack mechanism. Phase 34 lands the back-stack alongside its +first user (multi-turn projection, range circles in the ship-class +designer). + +## Auth gate + +The root `+layout.svelte` redirects `anonymous → /login` for any +non-`/__debug/` path; the in-game shell inherits that gate without +any extra check. When a session is revoked while the user is in the +shell, the same redirect fires through the existing +revocation watcher. diff --git a/ui/frontend/src/lib/active-view/battle.svelte b/ui/frontend/src/lib/active-view/battle.svelte new file mode 100644 index 0000000..61480ba --- /dev/null +++ b/ui/frontend/src/lib/active-view/battle.svelte @@ -0,0 +1,30 @@ + + + +
+

{i18n.t("game.view.battle")}

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/active-view/designer-science.svelte b/ui/frontend/src/lib/active-view/designer-science.svelte new file mode 100644 index 0000000..d5a7f05 --- /dev/null +++ b/ui/frontend/src/lib/active-view/designer-science.svelte @@ -0,0 +1,28 @@ + + + +
+

{i18n.t("game.view.designer.science")}

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/active-view/designer-ship-class.svelte b/ui/frontend/src/lib/active-view/designer-ship-class.svelte new file mode 100644 index 0000000..0dd5363 --- /dev/null +++ b/ui/frontend/src/lib/active-view/designer-ship-class.svelte @@ -0,0 +1,28 @@ + + + +
+

{i18n.t("game.view.designer.ship_class")}

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/active-view/mail.svelte b/ui/frontend/src/lib/active-view/mail.svelte new file mode 100644 index 0000000..2c1bb68 --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail.svelte @@ -0,0 +1,27 @@ + + + +
+

{i18n.t("game.view.mail")}

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte new file mode 100644 index 0000000..3fdbe8c --- /dev/null +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -0,0 +1,29 @@ + + + +
+

{i18n.t("game.view.map")}

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/active-view/report.svelte b/ui/frontend/src/lib/active-view/report.svelte new file mode 100644 index 0000000..35aba4e --- /dev/null +++ b/ui/frontend/src/lib/active-view/report.svelte @@ -0,0 +1,28 @@ + + + +
+

{i18n.t("game.view.report")}

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/active-view/table.svelte b/ui/frontend/src/lib/active-view/table.svelte new file mode 100644 index 0000000..bd15827 --- /dev/null +++ b/ui/frontend/src/lib/active-view/table.svelte @@ -0,0 +1,39 @@ + + + +
+

+ {i18n.t("game.view.table")}: {i18n.t(entityKey(entity))} +

+

{i18n.t("game.shell.coming_soon")}

+
+ + diff --git a/ui/frontend/src/lib/header/account-menu.svelte b/ui/frontend/src/lib/header/account-menu.svelte new file mode 100644 index 0000000..80bc601 --- /dev/null +++ b/ui/frontend/src/lib/header/account-menu.svelte @@ -0,0 +1,159 @@ + + + + + + diff --git a/ui/frontend/src/lib/header/header.svelte b/ui/frontend/src/lib/header/header.svelte new file mode 100644 index 0000000..f85e64f --- /dev/null +++ b/ui/frontend/src/lib/header/header.svelte @@ -0,0 +1,97 @@ + + + +
+
+ + {i18n.t("game.shell.race_placeholder")} + + +
+
+ + + +
+
+ + diff --git a/ui/frontend/src/lib/header/turn-counter.svelte b/ui/frontend/src/lib/header/turn-counter.svelte new file mode 100644 index 0000000..99e3570 --- /dev/null +++ b/ui/frontend/src/lib/header/turn-counter.svelte @@ -0,0 +1,21 @@ + + + + + {i18n.t("game.shell.turn_label")} {i18n.t("game.shell.turn_unknown")} + + + diff --git a/ui/frontend/src/lib/header/view-menu.svelte b/ui/frontend/src/lib/header/view-menu.svelte new file mode 100644 index 0000000..cbe5bd4 --- /dev/null +++ b/ui/frontend/src/lib/header/view-menu.svelte @@ -0,0 +1,254 @@ + + + +
+ + {#if open} + + {/if} +
+ + diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index e68cb4b..48207f7 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -82,6 +82,47 @@ const en = { "lobby.error.conflict": "request conflicts with current state", "lobby.error.internal_error": "internal server error", "lobby.error.unknown": "{message}", + + "game.shell.race_placeholder": "race ?", + "game.shell.turn_label": "turn", + "game.shell.turn_unknown": "?", + "game.shell.connection.online": "online", + "game.shell.connection.reconnecting": "reconnecting…", + "game.shell.connection.offline": "offline", + "game.shell.menu.toggle_sidebar": "open sidebar", + "game.shell.menu.close_sidebar": "close sidebar", + "game.shell.menu.open_views": "open views menu", + "game.shell.menu.close_views": "close views menu", + "game.shell.menu.account": "account", + "game.shell.menu.settings": "settings", + "game.shell.menu.sessions": "sessions", + "game.shell.menu.theme": "theme", + "game.shell.menu.language": "language", + "game.shell.menu.logout": "logout", + "game.shell.coming_soon": "coming soon", + "game.view.map": "map", + "game.view.table": "table", + "game.view.table.planets": "planets", + "game.view.table.ship_classes": "ship classes", + "game.view.table.ship_groups": "ship groups", + "game.view.table.fleets": "fleets", + "game.view.table.sciences": "sciences", + "game.view.table.races": "races", + "game.view.report": "turn report", + "game.view.battle": "battle log", + "game.view.mail": "diplomatic mail", + "game.view.designer.ship_class": "ship-class designer", + "game.view.designer.science": "science designer", + "game.sidebar.tab.calculator": "calculator", + "game.sidebar.tab.inspector": "inspector", + "game.sidebar.tab.order": "order", + "game.sidebar.empty.calculator": "coming soon", + "game.sidebar.empty.inspector": "select an object on the map", + "game.sidebar.empty.order": "coming soon", + "game.bottom_tabs.map": "map", + "game.bottom_tabs.calc": "calc", + "game.bottom_tabs.order": "order", + "game.bottom_tabs.more": "more", } as const; export default en; diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index c67b181..5199364 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -83,6 +83,47 @@ const ru: Record = { "lobby.error.conflict": "запрос конфликтует с текущим состоянием", "lobby.error.internal_error": "внутренняя ошибка сервера", "lobby.error.unknown": "{message}", + + "game.shell.race_placeholder": "раса ?", + "game.shell.turn_label": "ход", + "game.shell.turn_unknown": "?", + "game.shell.connection.online": "онлайн", + "game.shell.connection.reconnecting": "переподключение…", + "game.shell.connection.offline": "офлайн", + "game.shell.menu.toggle_sidebar": "открыть боковую панель", + "game.shell.menu.close_sidebar": "закрыть боковую панель", + "game.shell.menu.open_views": "открыть меню видов", + "game.shell.menu.close_views": "закрыть меню видов", + "game.shell.menu.account": "аккаунт", + "game.shell.menu.settings": "настройки", + "game.shell.menu.sessions": "сессии", + "game.shell.menu.theme": "тема", + "game.shell.menu.language": "язык", + "game.shell.menu.logout": "выйти", + "game.shell.coming_soon": "скоро будет", + "game.view.map": "карта", + "game.view.table": "таблица", + "game.view.table.planets": "планеты", + "game.view.table.ship_classes": "классы кораблей", + "game.view.table.ship_groups": "группы кораблей", + "game.view.table.fleets": "флоты", + "game.view.table.sciences": "науки", + "game.view.table.races": "расы", + "game.view.report": "отчёт хода", + "game.view.battle": "журнал боёв", + "game.view.mail": "дипломатическая почта", + "game.view.designer.ship_class": "конструктор класса кораблей", + "game.view.designer.science": "редактор наук", + "game.sidebar.tab.calculator": "калькулятор", + "game.sidebar.tab.inspector": "инспектор", + "game.sidebar.tab.order": "приказ", + "game.sidebar.empty.calculator": "скоро будет", + "game.sidebar.empty.inspector": "выберите объект на карте", + "game.sidebar.empty.order": "скоро будет", + "game.bottom_tabs.map": "карта", + "game.bottom_tabs.calc": "калк", + "game.bottom_tabs.order": "приказ", + "game.bottom_tabs.more": "ещё", }; export default ru; diff --git a/ui/frontend/src/lib/sidebar/bottom-tabs.svelte b/ui/frontend/src/lib/sidebar/bottom-tabs.svelte new file mode 100644 index 0000000..2b4febb --- /dev/null +++ b/ui/frontend/src/lib/sidebar/bottom-tabs.svelte @@ -0,0 +1,297 @@ + + + +
+
+ + + + +
+ {#if moreOpen} + + {/if} +
+ + diff --git a/ui/frontend/src/lib/sidebar/calculator-tab.svelte b/ui/frontend/src/lib/sidebar/calculator-tab.svelte new file mode 100644 index 0000000..cd5fe88 --- /dev/null +++ b/ui/frontend/src/lib/sidebar/calculator-tab.svelte @@ -0,0 +1,29 @@ + + + +
+

{i18n.t("game.sidebar.tab.calculator")}

+

{i18n.t("game.sidebar.empty.calculator")}

+
+ + diff --git a/ui/frontend/src/lib/sidebar/inspector-tab.svelte b/ui/frontend/src/lib/sidebar/inspector-tab.svelte new file mode 100644 index 0000000..f505278 --- /dev/null +++ b/ui/frontend/src/lib/sidebar/inspector-tab.svelte @@ -0,0 +1,29 @@ + + + +
+

{i18n.t("game.sidebar.tab.inspector")}

+

{i18n.t("game.sidebar.empty.inspector")}

+
+ + diff --git a/ui/frontend/src/lib/sidebar/order-tab.svelte b/ui/frontend/src/lib/sidebar/order-tab.svelte new file mode 100644 index 0000000..df6bf64 --- /dev/null +++ b/ui/frontend/src/lib/sidebar/order-tab.svelte @@ -0,0 +1,27 @@ + + + +
+

{i18n.t("game.sidebar.tab.order")}

+

{i18n.t("game.sidebar.empty.order")}

+
+ + diff --git a/ui/frontend/src/lib/sidebar/sidebar.svelte b/ui/frontend/src/lib/sidebar/sidebar.svelte new file mode 100644 index 0000000..a8796e6 --- /dev/null +++ b/ui/frontend/src/lib/sidebar/sidebar.svelte @@ -0,0 +1,130 @@ + + + + + + diff --git a/ui/frontend/src/lib/sidebar/tab-bar.svelte b/ui/frontend/src/lib/sidebar/tab-bar.svelte new file mode 100644 index 0000000..f95ca3d --- /dev/null +++ b/ui/frontend/src/lib/sidebar/tab-bar.svelte @@ -0,0 +1,64 @@ + + + +
+ {#each tabs as tab (tab.id)} + + {/each} +
+ + diff --git a/ui/frontend/src/lib/sidebar/types.ts b/ui/frontend/src/lib/sidebar/types.ts new file mode 100644 index 0000000..7e5a155 --- /dev/null +++ b/ui/frontend/src/lib/sidebar/types.ts @@ -0,0 +1,9 @@ +// Shared types for the in-game sidebar and the mobile bottom-tabs. +// Kept as plain TypeScript (instead of a Svelte module export) so +// every consumer — components, layout, and tests — imports them +// through the same path without relying on Svelte tooling for +// type-only re-exports. + +export type SidebarTab = "calculator" | "inspector" | "order"; + +export type MobileTool = "map" | "calc" | "order"; diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte new file mode 100644 index 0000000..0f67267 --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -0,0 +1,92 @@ + + + +
+
+
+
+ {#if effectiveTool === "calc"} + + {:else if effectiveTool === "order"} + + {:else} + {@render children()} + {/if} +
+ (sidebarOpen = false)} /> +
+ (mobileTool = tool)} + /> +
+ + diff --git a/ui/frontend/src/routes/games/[id]/+layout.ts b/ui/frontend/src/routes/games/[id]/+layout.ts new file mode 100644 index 0000000..ce3dd57 --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/+layout.ts @@ -0,0 +1,8 @@ +// SPA mode for the in-game shell, mirroring the root layout. The +// session bootstrap and the auth gate already live in the root +// `+layout.svelte`; this layout just inherits the SPA flags so the +// static adapter does not try to prerender a per-game shell at build +// time. + +export const ssr = false; +export const prerender = false; diff --git a/ui/frontend/src/routes/games/[id]/+page.ts b/ui/frontend/src/routes/games/[id]/+page.ts new file mode 100644 index 0000000..a48e9ec --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/+page.ts @@ -0,0 +1,12 @@ +// A bare `/games/:id` URL is not in the IA section — every in-game +// view sits under one of the typed sub-routes (`map`, `table/...`, +// etc.). Default the user to the map view so the URL is always +// pointing at a real active view; SvelteKit's `redirect` runs in the +// browser because the layout disables SSR. + +import { redirect } from "@sveltejs/kit"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = ({ params }) => { + throw redirect(307, `/games/${params.id}/map`); +}; diff --git a/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte b/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte new file mode 100644 index 0000000..d16714b --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte @@ -0,0 +1,6 @@ + + + diff --git a/ui/frontend/src/routes/games/[id]/designer/science/[[scienceId]]/+page.svelte b/ui/frontend/src/routes/games/[id]/designer/science/[[scienceId]]/+page.svelte new file mode 100644 index 0000000..5e28dc3 --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/designer/science/[[scienceId]]/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/ui/frontend/src/routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte b/ui/frontend/src/routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte new file mode 100644 index 0000000..212c8cc --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/ui/frontend/src/routes/games/[id]/mail/+page.svelte b/ui/frontend/src/routes/games/[id]/mail/+page.svelte new file mode 100644 index 0000000..6e27e97 --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/mail/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/ui/frontend/src/routes/games/[id]/map/+page.svelte b/ui/frontend/src/routes/games/[id]/map/+page.svelte new file mode 100644 index 0000000..4093cff --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/map/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/ui/frontend/src/routes/games/[id]/report/+page.svelte b/ui/frontend/src/routes/games/[id]/report/+page.svelte new file mode 100644 index 0000000..385e371 --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/report/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/ui/frontend/src/routes/games/[id]/table/[entity]/+page.svelte b/ui/frontend/src/routes/games/[id]/table/[entity]/+page.svelte new file mode 100644 index 0000000..ab28064 --- /dev/null +++ b/ui/frontend/src/routes/games/[id]/table/[entity]/+page.svelte @@ -0,0 +1,6 @@ + + + diff --git a/ui/frontend/tests/e2e/game-shell.spec.ts b/ui/frontend/tests/e2e/game-shell.spec.ts new file mode 100644 index 0000000..6b85053 --- /dev/null +++ b/ui/frontend/tests/e2e/game-shell.spec.ts @@ -0,0 +1,219 @@ +// Phase 10 end-to-end coverage for the in-game shell. Every spec +// boots an authenticated session through `/__debug/store` (no +// gateway calls — the shell makes none in Phase 10), navigates into +// `/games/test-shell/map`, and exercises one slice of the chrome: +// header navigation, sidebar tab preservation, mobile bottom-tabs, +// and the breakpoint switches at 768 / 1024 px. + +import { expect, test, type Page } from "@playwright/test"; + +// The `window.__galaxyDebug` surface is owned by +// `src/routes/__debug/store/+page.svelte` and typed by +// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only +// needs the auth-bootstrap subset (`clearSession`, +// `setDeviceSessionId`); the merged global declaration covers both. + +const SESSION_ID = "phase-10-shell-session"; +const GAME_ID = "test-shell"; + +async function bootShell(page: Page): Promise { + await page.goto("/__debug/store"); + await expect(page.getByTestId("debug-store-ready")).toBeVisible(); + await page.waitForFunction(() => window.__galaxyDebug?.ready === true); + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + await page.evaluate( + (id) => window.__galaxyDebug!.setDeviceSessionId(id), + SESSION_ID, + ); + await page.goto(`/games/${GAME_ID}/map`); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await expect(page.getByTestId("active-view-map")).toBeVisible(); +} + +test("shell mounts with header / sidebar / active-view chrome", async ({ + page, +}) => { + await bootShell(page); + await expect(page.getByTestId("game-shell-header")).toBeVisible(); + await expect(page.getByTestId("race-name")).toContainText("race ?"); + await expect(page.getByTestId("turn-counter")).toContainText("turn"); + await expect(page.getByTestId("view-menu-trigger")).toBeVisible(); + await expect(page.getByTestId("account-menu-trigger")).toBeVisible(); +}); + +test("header view-menu navigates to every active view", async ({ page }) => { + await bootShell(page); + + const destinations: Array<[string, string, string]> = [ + ["view-menu-item-report", "active-view-report", "/report"], + ["view-menu-item-mail", "active-view-mail", "/mail"], + ["view-menu-item-battle", "active-view-battle", "/battle"], + [ + "view-menu-item-designer-ship-class", + "active-view-designer-ship-class", + "/designer/ship-class", + ], + [ + "view-menu-item-designer-science", + "active-view-designer-science", + "/designer/science", + ], + ["view-menu-item-map", "active-view-map", "/map"], + ]; + + for (const [trigger, viewTestId, urlSuffix] of destinations) { + await page.getByTestId("view-menu-trigger").click(); + await page.getByTestId(trigger).click(); + await expect(page.getByTestId(viewTestId)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`)); + } +}); + +test("header view-menu Tables sub-list navigates to every entity", async ({ + page, +}) => { + await bootShell(page); + const entities = [ + "planets", + "ship-classes", + "ship-groups", + "fleets", + "sciences", + "races", + ]; + for (const entity of entities) { + await page.getByTestId("view-menu-trigger").click(); + await page + .getByTestId("view-menu-tables") + .locator("summary") + .click(); + await page.getByTestId(`view-menu-item-table-${entity}`).click(); + const view = page.getByTestId("active-view-table"); + await expect(view).toBeVisible(); + await expect(view).toHaveAttribute("data-entity", entity); + await expect(page).toHaveURL( + new RegExp(`/games/${GAME_ID}/table/${entity}$`), + ); + } +}); + +test("sidebar tab choice survives navigation between active views", async ({ + page, + browserName, +}, testInfo) => { + test.skip( + testInfo.project.name.startsWith("chromium-mobile") || + testInfo.project.name === "webkit-desktop" + ? false + : false, + "sidebar test runs on every project", + ); + await bootShell(page); + // Skip on viewports below 1024 — sidebar is hidden by CSS there. + const viewport = page.viewportSize(); + if (viewport === null || viewport.width < 1024) { + test.skip(); + return; + } + void browserName; + + await page.getByTestId("sidebar-tab-calculator").click(); + await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible(); + + await page.getByTestId("view-menu-trigger").click(); + await page.getByTestId("view-menu-item-report").click(); + await expect(page.getByTestId("active-view-report")).toBeVisible(); + + // Sidebar still rendered; the calculator tool remains selected. + await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible(); + await expect(page.getByTestId("sidebar")).toHaveAttribute( + "data-active-tab", + "calculator", + ); + + await page.getByTestId("view-menu-trigger").click(); + await page.getByTestId("view-menu-item-map").click(); + await expect(page.getByTestId("active-view-map")).toBeVisible(); + await expect(page.getByTestId("sidebar")).toHaveAttribute( + "data-active-tab", + "calculator", + ); +}); + +test("mobile bottom-tabs show on small viewports and toggle the tool overlay", async ({ + page, +}, testInfo) => { + if (!testInfo.project.name.startsWith("chromium-mobile")) { + test.skip(); + return; + } + await bootShell(page); + + await expect(page.getByTestId("bottom-tabs")).toBeVisible(); + await expect(page.getByTestId("sidebar")).not.toBeVisible(); + + await page.getByTestId("bottom-tab-calc").click(); + await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible(); + + await page.getByTestId("bottom-tab-order").click(); + await expect(page.getByTestId("sidebar-tool-order")).toBeVisible(); + + await page.getByTestId("bottom-tab-map").click(); + await expect(page.getByTestId("active-view-map")).toBeVisible(); +}); + +test("mobile More drawer navigates to every destination", async ({ + page, +}, testInfo) => { + if (!testInfo.project.name.startsWith("chromium-mobile")) { + test.skip(); + return; + } + await bootShell(page); + + await page.getByTestId("bottom-tab-more").click(); + await expect(page.getByTestId("bottom-tabs-more-drawer")).toBeVisible(); + await page.getByTestId("bottom-tabs-more-mail").click(); + await expect(page.getByTestId("active-view-mail")).toBeVisible(); + + await page.getByTestId("bottom-tab-more").click(); + await page.getByTestId("bottom-tabs-more-report").click(); + await expect(page.getByTestId("active-view-report")).toBeVisible(); +}); + +test("breakpoint switches between desktop / tablet / mobile", async ({ + page, +}, testInfo) => { + // Use a single chromium-desktop run to drive all three viewports in + // the same browser. Other projects skip — the viewport diff is the + // goal here, not browser-specific behaviour. + if (testInfo.project.name !== "chromium-desktop") { + test.skip(); + return; + } + await bootShell(page); + + // Desktop ≥ 1024: sidebar visible, bottom-tabs hidden, sidebar + // toggle hidden. + await page.setViewportSize({ width: 1280, height: 800 }); + await expect(page.getByTestId("sidebar")).toBeVisible(); + await expect(page.getByTestId("bottom-tabs")).not.toBeVisible(); + await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible(); + + // Tablet 768–1024: sidebar hidden by default, sidebar toggle + // visible, bottom-tabs hidden. Click the toggle and the sidebar + // becomes visible again. + await page.setViewportSize({ width: 900, height: 800 }); + await expect(page.getByTestId("sidebar")).not.toBeVisible(); + await expect(page.getByTestId("sidebar-toggle")).toBeVisible(); + await expect(page.getByTestId("bottom-tabs")).not.toBeVisible(); + await page.getByTestId("sidebar-toggle").click(); + await expect(page.getByTestId("sidebar")).toBeVisible(); + + // Mobile < 768: sidebar hidden entirely, bottom-tabs visible, + // sidebar toggle hidden again. + await page.setViewportSize({ width: 390, height: 800 }); + await expect(page.getByTestId("bottom-tabs")).toBeVisible(); + await expect(page.getByTestId("sidebar")).not.toBeVisible(); + await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible(); +}); diff --git a/ui/frontend/tests/game-shell-header.test.ts b/ui/frontend/tests/game-shell-header.test.ts new file mode 100644 index 0000000..abea334 --- /dev/null +++ b/ui/frontend/tests/game-shell-header.test.ts @@ -0,0 +1,140 @@ +// Component tests for the Phase 10 in-game shell header. The header +// composes the static `race ?` placeholder, the placeholder +// turn-counter (Phase 11 wires the live source), the view-menu, and +// the account-menu. The tests assert the placeholder copy, that +// every view-menu entry dispatches `goto` with the right URL, and +// that the Logout entry of the account-menu calls +// `session.signOut("user")`. + +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; +import { session } from "../src/lib/session-store.svelte"; +import Header from "../src/lib/header/header.svelte"; + +const gotoSpy = vi.fn(async (..._args: unknown[]) => {}); +vi.mock("$app/navigation", () => ({ + goto: (...args: unknown[]) => gotoSpy(...args), +})); + +beforeEach(() => { + i18n.resetForTests("en"); + gotoSpy.mockReset(); + vi.spyOn(session, "signOut").mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("game-shell header", () => { + test("renders the static race / turn placeholders and toggles", () => { + const onToggleSidebar = vi.fn(); + const ui = render(Header, { + props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, + }); + expect(ui.getByTestId("race-name")).toHaveTextContent("race ?"); + expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch( + /turn\s+\?/, + ); + expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument(); + expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument(); + }); + + test("clicking the sidebar toggle invokes the prop callback", async () => { + const onToggleSidebar = vi.fn(); + const ui = render(Header, { + props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, + }); + await fireEvent.click(ui.getByTestId("sidebar-toggle")); + expect(onToggleSidebar).toHaveBeenCalledTimes(1); + }); + + test("view-menu navigates to every IA destination", async () => { + const ui = render(Header, { + props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + }); + + const destinations: Array<[string, string]> = [ + ["view-menu-item-map", "/games/g1/map"], + ["view-menu-item-report", "/games/g1/report"], + ["view-menu-item-battle", "/games/g1/battle"], + ["view-menu-item-mail", "/games/g1/mail"], + [ + "view-menu-item-designer-ship-class", + "/games/g1/designer/ship-class", + ], + [ + "view-menu-item-designer-science", + "/games/g1/designer/science", + ], + ]; + + for (const [testId, href] of destinations) { + await fireEvent.click(ui.getByTestId("view-menu-trigger")); + await fireEvent.click(ui.getByTestId(testId)); + expect(gotoSpy).toHaveBeenLastCalledWith(href); + } + }); + + test("view-menu Tables sub-list navigates to every entity", async () => { + const ui = render(Header, { + props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + }); + const tableEntities: Array<[string, string]> = [ + ["view-menu-item-table-planets", "/games/g1/table/planets"], + [ + "view-menu-item-table-ship-classes", + "/games/g1/table/ship-classes", + ], + [ + "view-menu-item-table-ship-groups", + "/games/g1/table/ship-groups", + ], + ["view-menu-item-table-fleets", "/games/g1/table/fleets"], + ["view-menu-item-table-sciences", "/games/g1/table/sciences"], + ["view-menu-item-table-races", "/games/g1/table/races"], + ]; + for (const [testId, href] of tableEntities) { + await fireEvent.click(ui.getByTestId("view-menu-trigger")); + // Open the Tables sub-disclosure each iteration; the menu + // closes on every navigation. + const summary = ui + .getByTestId("view-menu-tables") + .querySelector("summary"); + if (summary !== null) { + await fireEvent.click(summary); + } + await fireEvent.click(ui.getByTestId(testId)); + expect(gotoSpy).toHaveBeenLastCalledWith(href); + } + }); + + test("account-menu Logout triggers session.signOut('user')", async () => { + const ui = render(Header, { + props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + }); + await fireEvent.click(ui.getByTestId("account-menu-trigger")); + await fireEvent.click(ui.getByTestId("account-menu-logout")); + expect(session.signOut).toHaveBeenCalledWith("user"); + }); + + test("account-menu language picker switches the i18n locale", async () => { + const ui = render(Header, { + props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + }); + await fireEvent.click(ui.getByTestId("account-menu-trigger")); + const select = ui.getByTestId("account-menu-language-select"); + await fireEvent.change(select, { target: { value: "ru" } }); + expect(i18n.locale).toBe("ru"); + }); +}); diff --git a/ui/frontend/tests/game-shell-sidebar.test.ts b/ui/frontend/tests/game-shell-sidebar.test.ts new file mode 100644 index 0000000..858e21b --- /dev/null +++ b/ui/frontend/tests/game-shell-sidebar.test.ts @@ -0,0 +1,98 @@ +// Component tests for the Phase 10 in-game shell sidebar. Validates +// the default selected tab, the Calculator / Inspector / Order +// switching, the empty-state copy that matches the IA section, and +// the `?sidebar=` URL seed convention used by the mobile bottom-tabs. + +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; + +const pageMock = vi.hoisted(() => ({ + url: new URL("http://localhost/games/g1/map"), + params: { id: "g1" } as Record, +})); + +vi.mock("$app/state", () => ({ + page: pageMock, +})); + +import Sidebar from "../src/lib/sidebar/sidebar.svelte"; + +beforeEach(() => { + i18n.resetForTests("en"); + pageMock.url = new URL("http://localhost/games/g1/map"); +}); + +describe("game-shell sidebar", () => { + test("renders the inspector tab content by default", () => { + const ui = render(Sidebar, { + props: { open: false, onClose: () => {} }, + }); + expect(ui.getByTestId("sidebar-tool-inspector")).toBeInTheDocument(); + expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent( + "select an object on the map", + ); + expect(ui.getByTestId("sidebar")).toHaveAttribute( + "data-active-tab", + "inspector", + ); + }); + + test("switching tabs updates the rendered tool", async () => { + const ui = render(Sidebar, { + props: { open: false, onClose: () => {} }, + }); + await fireEvent.click(ui.getByTestId("sidebar-tab-calculator")); + expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument(); + expect(ui.queryByTestId("sidebar-tool-inspector")).toBeNull(); + expect(ui.queryByTestId("sidebar-tool-order")).toBeNull(); + + await fireEvent.click(ui.getByTestId("sidebar-tab-order")); + expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument(); + expect(ui.queryByTestId("sidebar-tool-calculator")).toBeNull(); + }); + + test("empty-state copy matches the IA section verbatim", () => { + const ui = render(Sidebar, { + props: { open: false, onClose: () => {} }, + }); + expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent( + "select an object on the map", + ); + }); + + test("?sidebar=calc seeds the calculator tab on first mount", () => { + pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc"); + const ui = render(Sidebar, { + props: { open: false, onClose: () => {} }, + }); + expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument(); + expect(ui.getByTestId("sidebar")).toHaveAttribute( + "data-active-tab", + "calculator", + ); + }); + + test("?sidebar=order seeds the order tab on first mount", () => { + pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order"); + const ui = render(Sidebar, { + props: { open: false, onClose: () => {} }, + }); + expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument(); + }); + + test("close button calls the onClose prop", async () => { + const onClose = vi.fn(); + const ui = render(Sidebar, { props: { open: true, onClose } }); + await fireEvent.click(ui.getByTestId("sidebar-close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts new file mode 100644 index 0000000..0db8052 --- /dev/null +++ b/ui/frontend/tests/game-shell-stubs.test.ts @@ -0,0 +1,83 @@ +// Component tests for every Phase 10 active-view stub. Each stub +// renders the localised view title plus the `coming soon` body copy +// and exposes a stable `data-testid` so later phases can replace the +// content without renaming the test hook. The table stub additionally +// honours its `entity` prop and falls back to the snake_case i18n key +// for an unknown slug. + +import "@testing-library/jest-dom/vitest"; +import { render } from "@testing-library/svelte"; +import { beforeEach, describe, expect, test } from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; + +import MapView from "../src/lib/active-view/map.svelte"; +import TableView from "../src/lib/active-view/table.svelte"; +import ReportView from "../src/lib/active-view/report.svelte"; +import BattleView from "../src/lib/active-view/battle.svelte"; +import MailView from "../src/lib/active-view/mail.svelte"; +import DesignerShipClass from "../src/lib/active-view/designer-ship-class.svelte"; +import DesignerScience from "../src/lib/active-view/designer-science.svelte"; + +beforeEach(() => { + i18n.resetForTests("en"); +}); + +describe("active-view stubs", () => { + test("map stub renders title and coming-soon copy", () => { + const ui = render(MapView); + const node = ui.getByTestId("active-view-map"); + expect(node).toHaveTextContent("map"); + expect(node).toHaveTextContent("coming soon"); + }); + + test("table stub maps a kebab-case entity to the right i18n title", () => { + const ui = render(TableView, { props: { entity: "ship-classes" } }); + const node = ui.getByTestId("active-view-table"); + expect(node).toHaveAttribute("data-entity", "ship-classes"); + expect(node).toHaveTextContent("ship classes"); + expect(node).toHaveTextContent("coming soon"); + }); + + test("table stub also handles a single-word entity", () => { + const ui = render(TableView, { props: { entity: "planets" } }); + expect(ui.getByTestId("active-view-table")).toHaveTextContent("planets"); + }); + + test("report / mail / designer stubs render their localised titles", () => { + const r = render(ReportView); + expect(r.getByTestId("active-view-report")).toHaveTextContent( + "turn report", + ); + + const m = render(MailView); + expect(m.getByTestId("active-view-mail")).toHaveTextContent( + "diplomatic mail", + ); + + const sc = render(DesignerShipClass); + expect( + sc.getByTestId("active-view-designer-ship-class"), + ).toHaveTextContent("ship-class designer"); + + const sci = render(DesignerScience); + expect( + sci.getByTestId("active-view-designer-science"), + ).toHaveTextContent("science designer"); + }); + + test("battle stub stamps the battleId on the host element", () => { + const ui = render(BattleView, { props: { battleId: "b-42" } }); + const node = ui.getByTestId("active-view-battle"); + expect(node).toHaveAttribute("data-battle-id", "b-42"); + expect(node).toHaveTextContent("battle log"); + }); + + test("battle stub accepts an empty battleId for the list URL", () => { + const ui = render(BattleView, { props: { battleId: "" } }); + expect(ui.getByTestId("active-view-battle")).toHaveAttribute( + "data-battle-id", + "", + ); + }); +});