From 5b7b7bfbe779a5967e7e7d8f3b400638e254e7b1 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 14:22:26 -0500 Subject: [PATCH] feat(system-builder): MY SYSTEMS in the builder; SYSTEMS.EXE retired (4a2c2b) The builder's MY SYSTEMS page is MySystemsPage: OPEN saves the open system first, then opens the other here (onOpenSystem(id, page)); its messages go to the builder's status line. The builder opens with no system (systemId null): MY SYSTEMS only, the other pages and SAVE / PUBLISH off, NO SYSTEM OPEN in the top bar, nothing fetched but the list. The GAME tab's button is SYSTEM BUILDER and opens the builder on MY SYSTEMS, with the running system open when it is a custom one. The SYSTEMS.EXE window (components/SystemsWindow.tsx) and its tests are removed; its GAME-tab tests move to BuilderScreen.test.tsx. Approved mockup builder-my-systems, 2026-10-06. --- CHANGELOG.md | 6 + frontend/src/App.tsx | 31 +- frontend/src/components/AdminPanel.tsx | 8 +- frontend/src/components/BuilderScreen.tsx | 107 ++-- frontend/src/components/SystemsPanels.tsx | 4 +- frontend/src/components/SystemsWindow.tsx | 314 ------------ .../__tests__/BuilderScreen.test.tsx | 146 ++++-- .../__tests__/SystemsWindow.test.tsx | 455 ------------------ frontend/src/sheets/systemsApi.ts | 2 +- frontend/src/sheets/systemsLibrary.ts | 5 +- 10 files changed, 167 insertions(+), 911 deletions(-) delete mode 100644 frontend/src/components/SystemsWindow.tsx delete mode 100644 frontend/src/components/__tests__/SystemsWindow.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d179230..800deebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **One place for your systems: the builder.** The GAME tab's SYSTEMS.EXE button is now SYSTEM + BUILDER, and opens the builder on MY SYSTEMS, with the system the game runs already open when + it's one of yours. Everything SYSTEMS.EXE did happens there, so the SYSTEMS.EXE window is gone. + With no system open yet, MY SYSTEMS is the only page, and SAVE and PUBLISH wait until you open + or make one. + - **The builder's new MY SYSTEMS page, ready to be put in.** Every system as one line: its name and version, its description, who made it, when it changed, where it came from and how many characters are played in it, then its badges. Clicking a line opens it out with OPEN, RENAME, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43e46a7a..59d0ca20 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,8 +61,8 @@ import { useVehicleRoster } from './hooks/useVehicleRoster'; import { useEnemyVehicles } from './hooks/useEnemyVehicles'; import { hasVehicles } from './sheets/vehicleSystems'; import { NpcLibrary } from './components/NpcLibrary'; -import { SystemsWindow } from './components/SystemsWindow'; import { BuilderScreen } from './components/BuilderScreen'; +import { isCustomSystem } from './sheets/customTemplates'; import type { BuilderPage } from './sheets/builderSession'; import { NpcSheetWindow } from './components/NpcSheetWindow'; import { TvPortrait } from './components/TvPortrait'; @@ -301,10 +301,11 @@ function App() { const [isNpcLibraryOpen, setIsNpcLibraryOpen] = useState(false); const [npcLibraryPos, setNpcLibraryPos] = useState(() => ({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 200 })); - const [isSystemsOpen, setIsSystemsOpen] = useState(false); - /** The system the builder is open on, taking over the window (4a2b), and the page it opened at. */ - const [builder, setBuilder] = useState<{ id: string; page: BuilderPage } | null>(null); - const [systemsPos, setSystemsPos] = useState(() => ({ x: window.innerWidth / 2 - 310, y: window.innerHeight / 2 - 260 })); + /** + * The builder, taking over the window while open (4a2b): the system open in it (none until the + * GM picks one in MY SYSTEMS) and the page it opened at. + */ + const [builder, setBuilder] = useState<{ id: string | null; page: BuilderPage | 'systems' } | null>(null); const [openNpcSheet, setOpenNpcSheet] = useState<{ id: number; npc_label: string; token_shape?: string; locationId?: number } | null>(null); // NPC sheet linked to the currently selected token (admin) - drives // GENERATE_SHEET vs OPEN_SHEET on the token menu @@ -2045,7 +2046,10 @@ function App() { onGrantAccess={handleGrantAccess} onRevokeAccess={handleRevokeAccess} onOpenNpcLibrary={() => setIsNpcLibraryOpen(true)} - onOpenSystems={isPrimaryAdmin ? () => setIsSystemsOpen(true) : undefined} + onOpenSystems={isPrimaryAdmin + // On MY SYSTEMS, with the running system open when it is one of the GM's own. + ? () => setBuilder({ id: isCustomSystem(gameSystem) ? gameSystem : null, page: 'systems' }) + : undefined} /> )} {adminBankPlayer && ( @@ -2327,26 +2331,15 @@ function App() { attachLocationId={selectedLocation && ['enemy_rhombus', 'friendly_rhombus'].includes(selectedLocation.shape) ? selectedLocation.id : null} /> )} - {isSystemsOpen && token && isPrimaryAdmin && ( - setIsSystemsOpen(false)} - onOpenBuilder={(id, page) => { setIsSystemsOpen(false); setBuilder({ id, page }); }} - /> - )} {builder && token && isPrimaryAdmin && ( setBuilder(null)} - onOpenSystem={(id) => setBuilder({ id, page: 'setup' })} - onManageSystems={() => { setBuilder(null); setIsSystemsOpen(true); }} + onOpenSystem={(id, page) => setBuilder({ id, page })} /> )} {openPlayerSheetUser && token && ( diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 455ff329..c39479d8 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -2300,7 +2300,7 @@ const SR6_HOUSE_RULES: HouseRuleDef[] = [ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers }: { token: string; onOpenNpcLibrary?: () => void; activeUsers?: any[]; - /** Opens SYSTEMS.EXE; the main admin's alone, so absent for a granted editor. */ + /** Opens the system builder on MY SYSTEMS; the main admin's alone, so absent for a granted editor. */ onOpenSystems?: () => void; }) { const [open, setOpen] = useState(false); @@ -2320,7 +2320,7 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers }; useEffect(() => { if (open) refresh(); }, [open]); - // SYSTEMS.EXE renamed, copied or deleted one: the picker shows it at once. + // The builder renamed, copied, installed or deleted one: the picker shows it at once. useEffect(() => { const again = () => refresh(); window.addEventListener(SYSTEMS_CHANGED_EVENT, again); @@ -2351,9 +2351,9 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers Each system keeps its own characters, banks and token health; switching back restores them.

{onOpenSystems && <> - +

- Switching the game stays here, in the picker. SYSTEMS.EXE is for making and managing systems. + Switching the game stays here, in the picker. The builder is for making and managing systems.

} } defs={[ diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index bfdefd9f..80dc8332 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -4,7 +4,8 @@ import { createAutosave, saveStatus, exitWarning, leaveNeedsAsking, publishBlocked, publishedMessage, BUILDER_PAGES, type Autosave, type BuilderPage, } from '../sheets/builderSession'; -import { SYSTEMS_CHANGED_EVENT, badgesFor, versionLabel, type Badge, type LibrarySystem } from '../sheets/systemsLibrary'; +import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary'; +import { MySystemsPage } from './MySystemsPage'; // The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left // holds the system's name, its pages, SAVE and PUBLISH, and EXIT TO MAP; the open page fills the @@ -18,23 +19,23 @@ import { SYSTEMS_CHANGED_EVENT, badgesFor, versionLabel, type Badge, type Librar interface Props { token: string; - systemId: string; - /** The page it opens on: SETUP for a system just made. */ - startPage?: BuilderPage; + /** The system open in it; none when the GM has yet to pick one, and MY SYSTEMS is all there is. */ + systemId: string | null; + /** The page it opens on: SETUP for a system just made, MY SYSTEMS from the GAME tab. */ + startPage?: BuilderPage | 'systems'; /** The system the game runs, so PUBLISH can say whether the game now runs the new version. */ running: string | null; /** Back to the map. */ onExit: () => void; - /** Another system, opened here in its place (MY SYSTEMS). */ - onOpenSystem: (id: string) => void; - /** Back to SYSTEMS.EXE on the map, to rename, copy, share or install. */ - onManageSystems: () => void; + /** Another system, opened here in its place at a page (MY SYSTEMS), once the open one is saved. */ + onOpenSystem: (id: string, page: BuilderPage) => void; fetcher?: typeof fetch; } /** - * MY SYSTEMS is the builder's own list of systems, not a way out of it: picking one opens it here - * (the user asked, 2026-10-06, after it first went back to SYSTEMS.EXE on the map). + * MY SYSTEMS is the builder's own page for every system: picking, making, copying, sharing and + * installing them without leaving (MySystemsPage; approved mockup builder-my-systems, 2026-10-06, + * which retired the SYSTEMS.EXE window). */ const MY_SYSTEMS = { id: 'systems' as const, label: 'MY SYSTEMS', what: 'Every system you have made or installed. Pick one to work on it.' }; type Page = BuilderPage | 'systems'; @@ -62,10 +63,6 @@ const ExitIcon = () => ( /** How wide the rail grows to show the names. */ const RAIL_OPEN = 220; -const BADGE_BORDER: Record = { - run: 'var(--cyan)', plain: 'var(--dark-green)', warn: 'var(--warning)', bad: 'var(--danger)', -}; - const badge = (border: string, color?: string): React.CSSProperties => ({ fontSize: 10, letterSpacing: 1, padding: '1px 5px', border: `1px solid ${border}`, ...(color ? { color } : {}), }); @@ -128,12 +125,12 @@ function RailButton({ icon: glyph, label, onClick, title, active, disabled, coun ); } -export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, onManageSystems, fetcher }: Props) { +export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, fetcher }: Props) { const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]); const [system, setSystem] = useState(null); const [loadError, setLoadError] = useState(null); - const [page, setPage] = useState(startPage); - const [library, setLibrary] = useState<{ systems: LibrarySystem[] | null; error: string | null }>({ systems: null, error: null }); + // With no system open, MY SYSTEMS is the only page there is. + const [page, setPage] = useState(systemId ? startPage : 'systems'); const [status, setStatus] = useState<{ text: string; bad?: boolean } | null>(null); const [exitError, setExitError] = useState<{ error: string; then: () => void } | null>(null); const [busy, setBusy] = useState(false); @@ -142,6 +139,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o const autosave = useRef | null>(null); const load = useCallback(async () => { + if (!systemId) return null; const r = await api.get(systemId); if (!r.ok) { setLoadError(r.error); return null; } setSystem(r.value); @@ -151,7 +149,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o useEffect(() => { let live = true; load().then((sys) => { - if (!live || !sys) return; + if (!live || !sys || !systemId) return; autosave.current = createAutosave({ save: async (definition) => { const r = await api.saveDraft(systemId, definition); @@ -200,7 +198,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o if (!saved) { setBusy(false); return; } const blocked = publishBlocked(autosave.current.problems); if (blocked) { setBusy(false); setPage('problems'); setStatus({ text: blocked, bad: true }); return; } - const r = await api.publish(systemId); + const r = await api.publish(systemId!); setBusy(false); if (!r.ok) { if (r.problems) setPage('problems'); @@ -212,14 +210,6 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); }; - // MY SYSTEMS reads the list each time it opens, so it shows what was published or renamed since. - useEffect(() => { - if (page !== 'systems') return; - let live = true; - api.list().then((r) => { if (live) setLibrary(r.ok ? { systems: r.value, error: null } : { systems: null, error: r.error }); }); - return () => { live = false; }; - }, [api, page]); - const current = page === 'systems' ? MY_SYSTEMS : BUILDER_PAGES.find((p) => p.id === page)!; const saveLine = state ? saveStatus(state) : null; @@ -246,7 +236,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o }} >
- setPage('setup')} /> + setPage(systemId ? 'setup' : 'systems')} />
} label="EXIT TO MAP" aria="Exit the builder and go back to the map" onClick={() => leave(onExit)} /> @@ -259,7 +249,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o key={p.id} icon={Icons[p.id]} label={p.label} - title={p.what} + title={systemId ? p.what : 'Open a system first, in MY SYSTEMS.'} + disabled={!systemId} active={p.id === page} count={p.id === 'problems' && problems.length > 0 ? problems.length : undefined} onClick={() => { setPage(p.id); setStatus(null); }} @@ -281,7 +272,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o minWidth: 0, }}> - {name.toUpperCase() || '…'} + {systemId ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'} {system && (system.published ? PUBLISHED v{system.version} : NEVER PUBLISHED)} @@ -296,7 +287,16 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
{loadError &&

{loadError}

} - {!system && !loadError &&

LOADING…

} + {systemId && !system && !loadError &&

LOADING…

} + {page === 'systems' && (systemId === null || system) && ( + leave(() => onOpenSystem(id, at))} + say={(text, bad) => setStatus({ text, bad })} + /> + )} {system && page === 'problems' && (problems.length === 0 ?

No problems. It can be published.

: ( @@ -304,51 +304,6 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o {problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} ))} - {system && page === 'systems' && ( -
    - {library.error &&

    {library.error}

    } - {!library.systems && !library.error &&

    LOADING…

    } - {library.systems && ( -
    - {library.systems.map((s) => { - const here = s.id === systemId; - return ( - - ); - })} -
    - )} -

    - To rename, copy, share or install a system, use SYSTEMS.EXE on the map. -

    -
    - -
    -
    - )} {system && page !== 'problems' && page !== 'systems' && (

    {current.label}

    diff --git a/frontend/src/components/SystemsPanels.tsx b/frontend/src/components/SystemsPanels.tsx index efb40ae9..a5527246 100644 --- a/frontend/src/components/SystemsPanels.tsx +++ b/frontend/src/components/SystemsPanels.tsx @@ -4,8 +4,8 @@ import { insideBadges, installPlan, installedMessage, type Badge, type InstallPreview, type InstallAction, } from '../sheets/systemsLibrary'; -// The parts of managing a GM's systems that SYSTEMS.EXE and the builder's MY SYSTEMS page share -// (4a2c2a): NEW, INSTALL, the badges, and their look. What they say is sheets/systemsLibrary.ts. +// The builder MY SYSTEMS page's parts (4a2c2a; first built for the SYSTEMS.EXE window, retired in +// 4a2c2b): NEW, INSTALL, the badges, and their look. What they say is sheets/systemsLibrary.ts. /** Larger than any system file can be (the server's limit is about half a megabyte). */ export const MAX_FILE_BYTES = 1024 * 1024; diff --git a/frontend/src/components/SystemsWindow.tsx b/frontend/src/components/SystemsWindow.tsx deleted file mode 100644 index c74491b0..00000000 --- a/frontend/src/components/SystemsWindow.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { TerminalWindow, useFolder, type TerminalFolder } from './TerminalWindow'; -import type { BuilderPage } from '../sheets/builderSession'; -import { systemsApi } from '../sheets/systemsApi'; -import { Badges, NewPanel, InstallPanel, small, why, btn } from './SystemsPanels'; -import { - badgesFor, versionLabel, versionFact, originFact, changedFact, deleteBlocked, exportBlocked, - renamedMessage, duplicatedMessage, deletedMessage, createdMessage, SYSTEMS_CHANGED_EVENT, type LibrarySystem, -} from '../sheets/systemsLibrary'; - -// SYSTEMS.EXE (4a1): the main admin's own game systems. Opened from one button in the GAME tab's -// TTRPG_SYSTEM section; switching which system the game runs stays in the picker there. -// Approved mockup: docs/mockups/systems-library.html. What it says is sheets/systemsLibrary.ts, -// its requests sheets/systemsApi.ts. -// -// SYSTEMS lists them, with the picked one's facts and RENAME, DUPLICATE, EXPORT and DELETE. -// NEW makes one from a name; INSTALL previews a .citysys file, then installs it as it offers. -// A name refused because it is taken keeps its box open with what was typed and the reason -// under it, so the GM fixes it right there (decided with the user, 2026-10-06). A system already -// here is always offered UPDATE or KEEP BOTH, REPLACE asking first when it was changed here. - -type FolderId = 'systems' | 'new' | 'install'; -const FOLDERS: TerminalFolder[] = [ - { id: 'systems', label: 'SYSTEMS' }, - { id: 'new', label: 'NEW' }, - { id: 'install', label: 'INSTALL' }, -]; - -interface Props { - token: string; - /** The system the game runs. */ - running: string | null; - pos: { x: number; y: number }; - setPos: (p: { x: number; y: number }) => void; - onClose: () => void; - /** Opens the builder on a system, at a page: OPEN IN BUILDER, and CREATE (on SETUP). */ - onOpenBuilder?: (id: string, page: BuilderPage) => void; - fetcher?: typeof fetch; -} - -export function SystemsWindow({ token, running, pos, setPos, onClose, onOpenBuilder, fetcher }: Props) { - const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]); - const [folder, setFolder] = useFolder(FOLDERS, null); - const [systems, setSystems] = useState(null); - const [loadError, setLoadError] = useState(null); - const [picked, setPicked] = useState(null); - const [author, setAuthor] = useState(null); - const [renaming, setRenaming] = useState<{ text: string; error: string | null } | null>(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const [busy, setBusy] = useState(false); - const [status, setStatus] = useState<{ text: string; bad?: boolean } | null>(null); - const renameRef = useRef(null); - - /** The list, picking `pick` (or keeping the pick, or the first) once it arrives. */ - const load = useCallback(async (pick?: string | null) => { - const r = await api.list(); - if (!r.ok) { setLoadError(r.error); return; } - setLoadError(null); - setSystems(r.value); - setPicked((was) => { - const want = pick !== undefined ? pick : was; - return r.value.some((s) => s.id === want) ? want : (r.value[0]?.id ?? null); - }); - }, [api]); - - useEffect(() => { load(); }, [load]); - - const system = systems?.find((s) => s.id === picked) ?? null; - - // The author lives in the definition, so it is read for the picked system alone. - useEffect(() => { - setAuthor(null); - if (!picked) return; - let live = true; - api.get(picked).then((r) => { if (live && r.ok) setAuthor(r.value.draft?.author?.trim() || null); }); - return () => { live = false; }; - }, [api, picked]); - - const pick = (id: string) => { - setPicked(id); - setRenaming(null); - setConfirmDelete(false); - setStatus(null); - }; - - /** After a change: the list again, and the picker beside this window told. */ - const changed = async (pickAfter?: string | null) => { - await load(pickAfter); - window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); - }; - - const saveRename = async () => { - if (!system || !renaming || !renaming.text.trim() || busy) return; - setBusy(true); - const r = await api.rename(system.id, renaming.text); - setBusy(false); - if (!r.ok) { - // Stays open, holding what was typed, with the reason beside it. - setRenaming({ text: renaming.text, error: r.error }); - renameRef.current?.focus(); - return; - } - setRenaming(null); - setStatus({ text: renamedMessage(r.value.name) }); - await changed(); - }; - - const duplicate = async () => { - if (!system || busy) return; - setBusy(true); - const r = await api.duplicate(system.id); - setBusy(false); - if (!r.ok) { setStatus({ text: r.error, bad: true }); return; } - await changed(r.value.id); - setRenaming(null); - setConfirmDelete(false); - setStatus({ text: duplicatedMessage(r.value.name) }); - }; - - const exportFile = async () => { - if (!system || busy) return; - setBusy(true); - const r = await api.exportFile(system.id); - setBusy(false); - if (!r.ok) { setStatus({ text: r.error, bad: true }); return; } - const url = URL.createObjectURL(new Blob([r.value.text], { type: 'application/json' })); - const a = document.createElement('a'); - a.href = url; - a.download = r.value.fileName; - a.click(); - URL.revokeObjectURL(url); - setStatus({ text: `Downloading ${r.value.fileName} (v${system.version}).` }); - }; - - const remove = async () => { - if (!system || busy) return; - setBusy(true); - const r = await api.remove(system.id); - setBusy(false); - setConfirmDelete(false); - if (!r.ok) { setStatus({ text: r.error, bad: true }); return; } - setStatus({ text: deletedMessage(system.name) }); - await changed(null); - }; - - const openFolder = (id: FolderId) => { - setFolder(id); - setStatus(null); - setRenaming(null); - setConfirmDelete(false); - }; - - /** Made in NEW: the builder opens it on SETUP (decided 2026-10-03); without one, it is shown picked. */ - const made = async (id: string, name: string) => { - await changed(id); - if (onOpenBuilder) { onOpenBuilder(id, 'setup'); return; } - setFolder('systems'); - setStatus({ text: createdMessage(name) }); - }; - - /** Installed: the line says what happened, and the list behind it has it. */ - const installed = async (id: string, message: string) => { - await changed(id); - setStatus({ text: message }); - }; - - const deleteWhy = system ? deleteBlocked(system, running) : null; - const exportWhy = system ? exportBlocked(system) : null; - - const list = systems && ( -
    - {systems.map((s) => ( - - ))} -
    - ); - - const detail = system && ( -
    -
    -
    NAME
    {system.name}
    -
    VERSION
    {versionFact(system)}
    - {author && <>
    AUTHOR
    {author}
    } -
    CHANGED
    {changedFact(system.updatedAt)}
    -
    FROM
    {originFact(system)}
    -
    - - {renaming && ( -
    -
    - setRenaming({ text: e.target.value, error: null })} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); saveRename(); } - if (e.key === 'Escape') { e.preventDefault(); setRenaming(null); } - }} - style={{ - flex: 1, minWidth: 0, background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, - padding: '5px 7px', border: `1px solid ${renaming.error ? 'var(--danger)' : 'var(--green)'}`, - }} - /> - - -
    - {renaming.error - ? {renaming.error} - : The new name shows everywhere at once: this list, the game-system picker and the running game.} -
    - )} - -
    - {onOpenBuilder && ( - - )} - - - - -
    - {deleteWhy && DELETE: {deleteWhy}} - {exportWhy && EXPORT: {exportWhy}} - - {confirmDelete && ( -
    - DELETE {system.name.toUpperCase()}? - - It leaves every list and can't be run. Its characters, banks and token health are kept: installing its file again brings all of it back. - -
    - - -
    -
    - )} -
    - ); - - const header = { - systems: - YOUR SYSTEMS{systems ? ` · ${systems.length}` : ''}MAIN ADMIN ONLY - , - new: 'NEW SYSTEM', - install: 'INSTALL A .CITYSYS FILE', - }[folder]; - - return ( - -
    - {folder === 'systems' && <> - {loadError && {loadError}} - {!systems && !loadError && LOADING…} - {systems && systems.length === 0 && No systems of your own yet. Make one in NEW, or install a file in INSTALL.} - {list} - {detail} - } - {folder === 'new' && } - {folder === 'install' && } - {/* Always there, so a screen reader hears each new line; with nothing to say it takes - no room, gap included, so what is above it sits centered in the panel. */} -
    - {status?.text ?? ''} -
    -
    -
    - ); -} diff --git a/frontend/src/components/__tests__/BuilderScreen.test.tsx b/frontend/src/components/__tests__/BuilderScreen.test.tsx index 07b85458..96375883 100644 --- a/frontend/src/components/__tests__/BuilderScreen.test.tsx +++ b/frontend/src/components/__tests__/BuilderScreen.test.tsx @@ -3,7 +3,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor, within, fireEvent, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +vi.mock('../../utils/locationHelpers', () => ({ + isUserDefinedName: (name: string) => !!name && name.trim() !== '', + getStructLabel: (loc: any) => `STRUCT_${loc.id}`, +})); +vi.mock('../../assets/Credits.png', () => ({ default: 'credits.png' })); +vi.mock('../DraggableWindow', () => ({ + DraggableWindow: ({ children, title }: any) =>
    {title}
    {children}
    , +})); + import { BuilderScreen } from '../BuilderScreen'; +import { AdminPanel } from '../AdminPanel'; import { SYSTEMS_CHANGED_EVENT } from '../../sheets/systemsLibrary'; import type { SystemCopies } from '../../sheets/systemsApi'; @@ -19,7 +29,7 @@ const PROBLEM = { where: 'derived armor', message: 'Depends on itself: armor → const EMBER = 'sys_bbbbbbbbbbbbbbbb'; const row = (id: string, name: string, over = {}) => ({ id, name, version: 2, updatedAt: '2026-10-06 14:10:00', publishedAt: null, published: true, - unpublishedChanges: false, installed: false, problemCount: 0, ...over, + unpublishedChanges: false, installed: false, problemCount: 0, description: '', author: '', characterCount: 0, ...over, }); const LIBRARY = [row(HEARTH, 'Hearth', { version: 3 }), row(EMBER, 'Ember', { installed: true, problemCount: 1 })]; @@ -54,10 +64,9 @@ afterEach(() => cleanup()); const open = (over: Partial> = {}) => { const onExit = vi.fn(); const onOpenSystem = vi.fn(); - const onManageSystems = vi.fn(); render(); - return { onExit, onOpenSystem, onManageSystems }; + fetcher={fakeServer as typeof fetch} {...over} />); + return { onExit, onOpenSystem }; }; const sidebar = () => screen.getByRole('navigation', { name: 'Builder' }); const ready = () => waitFor(() => expect(screen.getByTestId('save-status')).toBeTruthy()); @@ -195,63 +204,79 @@ describe('PUBLISH', () => { describe('leaving', () => { it('EXIT TO MAP goes straight back when nothing is unsaved', async () => { - const { onExit, onManageSystems, onOpenSystem } = open(); + const { onExit, onOpenSystem } = open(); await ready(); await userEvent.click(screen.getByLabelText('Exit the builder and go back to the map')); expect(onExit).toHaveBeenCalledTimes(1); - expect(onManageSystems).not.toHaveBeenCalled(); expect(onOpenSystem).not.toHaveBeenCalled(); }); }); describe('MY SYSTEMS', () => { - const openList = async (over = {}) => { - const props = open(over); + it('is the builder\'s page for every system, the open one opened out', async () => { + const { onExit } = open({ running: HEARTH }); await ready(); await userEvent.click(within(sidebar()).getByLabelText('MY SYSTEMS')); await waitFor(() => expect(screen.getByRole('group', { name: 'Your systems' })).toBeTruthy()); - return props; - }; - const entries = () => within(screen.getByRole('group', { name: 'Your systems' })).getAllByRole('button') as HTMLButtonElement[]; - - it('is a page of the builder, listing every system with its badges, this one marked', async () => { - const { onExit, onManageSystems } = await openList({ running: HEARTH }); expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('MY SYSTEMS'); expect(within(sidebar()).getByLabelText('MY SYSTEMS').getAttribute('aria-current')).toBe('page'); - expect(entries().map((b) => b.textContent)).toEqual([ - 'HEARTHOPEN NOWRUNNINGPUBLISHED v3', - 'EMBERv2PUBLISHED v21 PROBLEMINSTALLED', - ]); - expect(entries()[0].disabled).toBe(true); + expect(within(screen.getByTestId(`system-${HEARTH}`)).getByText('OPEN NOW IN THE BUILDER')).toBeTruthy(); + expect(screen.queryByText('OPEN SYSTEMS.EXE')).toBeNull(); expect(onExit).not.toHaveBeenCalled(); - expect(onManageSystems).not.toHaveBeenCalled(); }); - it('opens another system here, saving first', async () => { - const { onOpenSystem, onExit } = await openList(); - await userEvent.click(entries()[1]); - expect(onOpenSystem).toHaveBeenCalledWith(EMBER); + it('OPEN on another system opens it here, on SETUP', async () => { + const { onOpenSystem, onExit } = open(); + await ready(); + await userEvent.click(within(sidebar()).getByLabelText('MY SYSTEMS')); + await waitFor(() => expect(screen.getByTestId(`system-${EMBER}`)).toBeTruthy()); + await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getAllByRole('button')[0]); + await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getByText('OPEN')); + await waitFor(() => expect(onOpenSystem).toHaveBeenCalledWith(EMBER, 'setup')); expect(onExit).not.toHaveBeenCalled(); }); - it('sends renaming, copying, sharing and installing to SYSTEMS.EXE', async () => { - const { onManageSystems, onOpenSystem } = await openList(); - await userEvent.click(screen.getByText('OPEN SYSTEMS.EXE')); - expect(onManageSystems).toHaveBeenCalledTimes(1); - expect(onOpenSystem).not.toHaveBeenCalled(); - }); - - it('says when the list can\'t be read', async () => { - const down = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => (String(input) === '/api/systems' - ? { ok: false, status: 500, json: async () => ({ error: 'Could not reach the systems store' }) } as unknown as Response - : fakeServer(input, init))); - open({ fetcher: down as typeof fetch }); + it('says what it did on the builder\'s status line', async () => { + open(); await ready(); await userEvent.click(within(sidebar()).getByLabelText('MY SYSTEMS')); - expect((await screen.findByRole('alert')).textContent).toBe('Could not reach the systems store'); + await waitFor(() => expect(screen.getByTestId(`system-${EMBER}`)).toBeTruthy()); + await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getAllByRole('button')[0]); + await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getByText('DUPLICATE')); + // The fake server has no duplicate route, so the refusal is what the status line shows. + await waitFor(() => expect(screen.getByRole('status').textContent).toBe('No such route')); + }); +}); + +describe('with no system open', () => { + it('opens on MY SYSTEMS, the only page there is, with nothing to save or publish', async () => { + const { onOpenSystem } = open({ systemId: null, startPage: 'setup' }); + await waitFor(() => expect(screen.getByRole('group', { name: 'Your systems' })).toBeTruthy()); + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('MY SYSTEMS'); + expect(within(screen.getByTestId('builder-system')).getByText('NO SYSTEM OPEN')).toBeTruthy(); + for (const label of ['SETUP', 'WORDS', 'PROBLEMS', 'SAVE', 'PUBLISH']) { + expect((within(sidebar()).getByLabelText(label) as HTMLButtonElement).disabled, label).toBe(true); + } + expect(within(sidebar()).getByLabelText('SETUP').title).toBe('Open a system first, in MY SYSTEMS.'); + expect(screen.queryByTestId('save-status')).toBeNull(); + // Only the list is read: there is no system to fetch. + expect(calls.map((c) => c.url)).toEqual(['/api/systems']); + expect(screen.queryByRole('alert')).toBeNull(); + // Nothing is open, so OPEN goes straight there. + await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getAllByRole('button')[0]); + await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getByText('OPEN')); + await waitFor(() => expect(onOpenSystem).toHaveBeenCalledWith(EMBER, 'setup')); + }); + + it('EXIT TO MAP still leaves', async () => { + const { onExit } = open({ systemId: null }); + await userEvent.click(screen.getByLabelText('Exit the builder and go back to the map')); + expect(onExit).toHaveBeenCalledTimes(1); }); +}); - it('closing the browser tab doesn\'t ask while everything is saved', async () => { +describe('closing the browser tab', () => { + it('doesn\'t ask while everything is saved', async () => { open(); await ready(); const e = new Event('beforeunload', { cancelable: true }); @@ -259,3 +284,48 @@ describe('MY SYSTEMS', () => { expect(e.defaultPrevented).toBe(false); }); }); + +describe('the GAME tab', () => { + let pickerFetches = 0; + beforeEach(() => { + pickerFetches = 0; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (String(url) === '/api/sheets/system') pickerFetches += 1; + return { ok: true, json: async () => (String(url) === '/api/sheets/system' ? { system: 'generic', systems: [] } : []) }; + })); + }); + afterEach(() => vi.unstubAllGlobals()); + const props = (onOpenSystems?: () => void): any => ({ + socketRef: { current: { emit: vi.fn(), on: vi.fn(), off: vi.fn() } }, token: 'admintoken', onLogout: vi.fn(), + refreshLocations: vi.fn(), refreshRoads: vi.fn(), locations: [], roads: [], editData: {}, setEditData: vi.fn(), + editId: null, setEditId: vi.fn(), view: 'list', setView: vi.fn(), pendingRequests: [], setPendingRequests: vi.fn(), + selectedIds: [], setSelectedIds: vi.fn(), districts: [], fetchDistricts: vi.fn(), districtConfig: {}, setDistrictConfig: vi.fn(), + fetchGlobalSettings: vi.fn(), activeUsers: [], setIsAdminPayOpen: vi.fn(), handleSaveDefault: vi.fn(), handleLoadDefault: vi.fn(), + globalSettings: { game_system: 'generic' }, gameSystem: 'generic', onOpenSystems, + }); + const ttrpg = async (onOpenSystems?: () => void) => { + render(); + await userEvent.click(screen.getByText('GAME')); + await userEvent.click(screen.getByText(/TTRPG_SYSTEM/)); + }; + + it('opens the builder from one SYSTEM BUILDER button under the picker', async () => { + const opened = vi.fn(); + await ttrpg(opened); + await userEvent.click(screen.getByText('SYSTEM BUILDER')); + expect(opened).toHaveBeenCalledTimes(1); + expect(screen.queryByText('SYSTEMS.EXE')).toBeNull(); + }); + + it('has no button for anyone but the main admin', async () => { + await ttrpg(undefined); + expect(screen.queryByText('SYSTEM BUILDER')).toBeNull(); + }); + + it('fetches the picker\'s list again when the builder changes a system', async () => { + await ttrpg(vi.fn()); + await waitFor(() => expect(pickerFetches).toBe(1)); + window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); + await waitFor(() => expect(pickerFetches).toBe(2)); + }); +}); diff --git a/frontend/src/components/__tests__/SystemsWindow.test.tsx b/frontend/src/components/__tests__/SystemsWindow.test.tsx deleted file mode 100644 index ba373f30..00000000 --- a/frontend/src/components/__tests__/SystemsWindow.test.tsx +++ /dev/null @@ -1,455 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock('../../utils/locationHelpers', () => ({ - isUserDefinedName: (name: string) => !!name && name.trim() !== '', - getStructLabel: (loc: any) => `STRUCT_${loc.id}`, -})); -vi.mock('../../assets/Credits.png', () => ({ default: 'credits.png' })); -vi.mock('../DraggableWindow', () => ({ - DraggableWindow: ({ children, title }: any) =>
    {title}
    {children}
    , -})); - -import { SystemsWindow } from '../SystemsWindow'; -import { AdminPanel } from '../AdminPanel'; -import { SYSTEMS_CHANGED_EVENT, type LibrarySystem, type InstallPreview } from '../../sheets/systemsLibrary'; - -/** - * SYSTEMS.EXE (4a1c1): the main admin's systems, opened from the GAME tab. Decided with the user: - * a window opened from one button under the game-system picker, which stays outside it - * (2026-10-02); a rename refused for a taken name stays open with the reason (2026-10-06). - */ - -const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; -const NEON = 'sys_bbbbbbbbbbbbbbbb'; -const VAULT = 'sys_cccccccccccccccc'; -const COPY = 'sys_dddddddddddddddd'; - -const row = (over: Partial): LibrarySystem => ({ - id: HEARTH, name: 'Hearth', version: 3, updatedAt: '2026-10-06 14:10:00', publishedAt: '2026-10-06 14:10:00', - published: true, unpublishedChanges: false, installed: false, problemCount: 0, - description: '', author: '', characterCount: 0, ...over, -}); - -let systems: LibrarySystem[]; -let calls: { url: string; method: string; body: unknown }[]; -/** How the server answers a rename, a preview and an install; a test changes them. */ -let renameAnswer: { status: number; body: unknown } | null; -let previewAnswer: { status: number; body: unknown }; -let installAnswer: { status: number; body: unknown }; - -const INSIDE = { words: 12, partsOff: 0, currencies: 2, derived: 0, lookups: 0, sheetFields: 41, npcTiers: 0, healthModel: null }; -const PREVIEW: InstallPreview = { - manifest: { name: 'Iron Sea', author: 'M. Okafor', license: 'CC BY 4.0', builder: '1.15.0', version: 4, origin: 'org_iron' }, - name: 'Iron Sea', inside: INSIDE, problems: [], installed: [], restores: null, - installsAs: { new: 'Iron Sea', update: null, keep_both: 'Iron Sea' }, -}; - -const fakeServer = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const body = init?.body ? JSON.parse(String(init.body)) : undefined; - calls.push({ url, method, body }); - const json = (status: number, b: unknown, headers: Record = {}) => ({ - ok: status < 300, status, json: async () => b, text: async () => String(b), headers: { get: (k: string) => headers[k] ?? null }, - }) as unknown as Response; - if (url === '/api/systems' && method === 'GET') return json(200, systems); - if (url === '/api/systems' && method === 'POST') { - const taken = systems.find((s) => s.name.toLowerCase() === body.name.trim().toLowerCase()); - if (taken) return json(409, { error: `Another system is already called ${taken.name}.` }); - systems = [row({ id: COPY, name: body.name, published: false, version: 0, unpublishedChanges: true }), ...systems]; - return json(200, { id: COPY, problems: [] }); - } - if (url === '/api/systems/install/preview') return json(previewAnswer.status, previewAnswer.body); - if (url === '/api/systems/install') { - if (installAnswer.status === 200) systems = [row({ id: COPY, name: (installAnswer.body as { name: string }).name, installed: true }), ...systems]; - return json(installAnswer.status, installAnswer.body); - } - const m = /^\/api\/systems\/(sys_[0-9a-f]+)(\/[a-z]+)?$/.exec(url); - if (m) { - const [, id, rest] = m; - const s = systems.find((x) => x.id === id)!; - if (!rest && method === 'GET') return json(200, { draft: { author: id === HEARTH ? 'Cody' : '' } }); - if (rest === '/name') { - if (renameAnswer) return json(renameAnswer.status, renameAnswer.body); - s.name = body.name.trim(); - return json(200, { name: s.name }); - } - if (rest === '/duplicate') { - systems = [row({ id: COPY, name: `${s.name} copy`, published: false, version: 0, unpublishedChanges: true }), ...systems]; - return json(200, { id: COPY, name: `${s.name} copy` }); - } - if (rest === '/export') return json(200, '{"citysys":1}', { 'Content-Disposition': 'attachment; filename="hearth.citysys"' }); - if (!rest && method === 'DELETE') { systems = systems.filter((x) => x.id !== id); return json(200, { deleted: true }); } - } - return json(404, { error: 'No such route' }); -}); - -beforeEach(() => { - calls = []; - renameAnswer = null; - previewAnswer = { status: 200, body: PREVIEW }; - installAnswer = { status: 200, body: { id: COPY, name: 'Iron Sea', published: true, problems: [] } }; - systems = [ - row({}), - row({ id: NEON, name: 'Neon Exchange', version: 1, unpublishedChanges: true }), - row({ id: VAULT, name: 'Vault Knights', version: 0, published: false, unpublishedChanges: true, installed: true, problemCount: 3 }), - ]; -}); -afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); - -const open = (running: string | null = HEARTH) => render( - , -); -const listed = () => within(screen.getByRole('group', { name: 'Your systems' })).getAllByRole('button'); -const entry = (name: string) => listed().find((b) => b.textContent!.startsWith(name.toUpperCase()))!; - -describe('the list', () => { - it('shows every system with its badges, the first picked with its facts', async () => { - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - expect(listed().map((b) => b.textContent)).toEqual([ - 'HEARTHv3RUNNINGPUBLISHED v3', - 'NEON EXCHANGEv1PUBLISHED v1UNPUBLISHED CHANGES', - 'VAULT KNIGHTSDRAFTNEVER PUBLISHED3 PROBLEMSINSTALLED', - ]); - expect(entry('Hearth').getAttribute('aria-pressed')).toBe('true'); - expect(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · HEARTH'); - expect(screen.getByText('YOUR SYSTEMS · 3')).toBeTruthy(); - expect(await screen.findByText('Cody')).toBeTruthy(); - expect(screen.getByText('v3 published')).toBeTruthy(); - expect(screen.getByText('Made here')).toBeTruthy(); - }); - - it('picks another, with its own facts and no author it hasn\'t got', async () => { - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(entry('Vault Knights')); - expect(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · VAULT KNIGHTS'); - expect(screen.getByText('Never published')).toBeTruthy(); - expect(screen.getByText('Installed from a file')).toBeTruthy(); - await waitFor(() => expect(calls.some((c) => c.url === `/api/systems/${VAULT}`)).toBe(true)); - expect(screen.queryByText('AUTHOR')).toBeNull(); - }); - - it('says when there are none, and when the server can\'t be reached', async () => { - systems = []; - open(); - expect(await screen.findByText('No systems of your own yet. Make one in NEW, or install a file in INSTALL.')).toBeTruthy(); - expect(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · LIBRARY'); - cleanup(); - render( { throw new TypeError('down'); }) as never} />); - expect((await screen.findByRole('alert')).textContent).toBe('Could not reach the server.'); - }); -}); - -describe('RENAME', () => { - it('renames at once, telling the picker beside it', async () => { - const told = vi.fn(); - window.addEventListener(SYSTEMS_CHANGED_EVENT, told); - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(screen.getByText('RENAME')); - const input = screen.getByLabelText('New name') as HTMLInputElement; - expect(input.value).toBe('Hearth'); - await userEvent.clear(input); - await userEvent.type(input, 'Emberhold{Enter}'); - expect(calls.find((c) => c.method === 'PUT')).toEqual({ url: `/api/systems/${HEARTH}/name`, method: 'PUT', body: { name: 'Emberhold' } }); - expect(await screen.findByText('Renamed to Emberhold.')).toBeTruthy(); - await waitFor(() => expect(entry('Emberhold')).toBeTruthy()); - expect(screen.queryByLabelText('New name')).toBeNull(); - expect(told).toHaveBeenCalledTimes(1); - window.removeEventListener(SYSTEMS_CHANGED_EVENT, told); - }); - - it('refused, stays open where it is with what was typed and why', async () => { - renameAnswer = { status: 409, body: { error: 'Another system is already called Neon Exchange.' } }; - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(screen.getByText('RENAME')); - const input = screen.getByLabelText('New name') as HTMLInputElement; - await userEvent.clear(input); - await userEvent.type(input, 'neon exchange'); - await userEvent.click(screen.getByText('SAVE')); - expect((await screen.findByRole('alert')).textContent).toBe('Another system is already called Neon Exchange.'); - expect((screen.getByLabelText('New name') as HTMLInputElement).value).toBe('neon exchange'); - expect(document.activeElement).toBe(screen.getByLabelText('New name')); - expect(screen.getByLabelText('New name').getAttribute('aria-invalid')).toBe('true'); - // Typing again clears the reason. - await userEvent.type(input, 's'); - expect(screen.queryByRole('alert')).toBeNull(); - }); - - it('won\'t save a blank name, and CANCEL or Esc puts it away', async () => { - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(screen.getByText('RENAME')); - await userEvent.clear(screen.getByLabelText('New name')); - expect((screen.getByText('SAVE') as HTMLButtonElement).disabled).toBe(true); - await userEvent.click(screen.getByText('CANCEL')); - expect(screen.queryByLabelText('New name')).toBeNull(); - await userEvent.click(screen.getByText('RENAME')); - await userEvent.type(screen.getByLabelText('New name'), '{Escape}'); - expect(screen.queryByLabelText('New name')).toBeNull(); - expect(calls.some((c) => c.method === 'PUT')).toBe(false); - }); -}); - -describe('DUPLICATE', () => { - it('makes the copy and picks it', async () => { - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(screen.getByText('DUPLICATE')); - expect(await screen.findByText('Copied as Hearth copy: a draft, its own system.')).toBeTruthy(); - await waitFor(() => expect(listed()).toHaveLength(4)); - expect(entry('Hearth copy').getAttribute('aria-pressed')).toBe('true'); - }); -}); - -describe('EXPORT', () => { - it('downloads a published system\'s file under its own name', async () => { - const created = vi.fn(() => 'blob:x'); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() })); - const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(screen.getByText('EXPORT .CITYSYS')); - expect(await screen.findByText('Downloading hearth.citysys (v3).')).toBeTruthy(); - expect(click).toHaveBeenCalledTimes(1); - expect((click.mock.instances[0] as unknown as HTMLAnchorElement).download).toBe('hearth.citysys'); - click.mockRestore(); - }); - - it('is off for one never published, saying why', async () => { - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(entry('Vault Knights')); - expect((screen.getByText('EXPORT .CITYSYS') as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByText('EXPORT: Publish it before sharing it. Only a published system is shared.')).toBeTruthy(); - }); -}); - -describe('DELETE', () => { - it('is off for the system the game runs, saying why', async () => { - open(HEARTH); - await waitFor(() => expect(listed()).toHaveLength(3)); - expect((screen.getByText('DELETE') as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByText('DELETE: This is the system the game is running. Switch to another first.')).toBeTruthy(); - }); - - it('asks first; KEEP IT keeps it, DELETE hides it', async () => { - open('cities_without_number'); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(entry('Neon Exchange')); - await userEvent.click(screen.getByText('DELETE')); - const ask = screen.getByRole('alertdialog', { name: 'Delete Neon Exchange' }); - await userEvent.click(within(ask).getByText('KEEP IT')); - expect(screen.queryByRole('alertdialog')).toBeNull(); - await userEvent.click(screen.getByText('DELETE')); - await userEvent.click(within(screen.getByRole('alertdialog')).getByText('DELETE')); - expect(await screen.findByText('Deleted Neon Exchange. Installing its file brings it back.')).toBeTruthy(); - await waitFor(() => expect(listed()).toHaveLength(2)); - expect(calls.find((c) => c.method === 'DELETE')!.url).toBe(`/api/systems/${NEON}`); - expect(entry('Hearth').getAttribute('aria-pressed')).toBe('true'); - }); -}); - -const folder = async (name: 'SYSTEMS' | 'NEW' | 'INSTALL') => { - await waitFor(() => expect(listed().length).toBeGreaterThan(0)); - await userEvent.click(screen.getByRole('tab', { name })); -}; - -describe('the builder', () => { - it('OPEN IN BUILDER opens the picked system there', async () => { - const onOpenBuilder = vi.fn(); - render(); - await waitFor(() => expect(listed()).toHaveLength(3)); - await userEvent.click(entry('Neon Exchange')); - await userEvent.click(screen.getByText('OPEN IN BUILDER')); - expect(onOpenBuilder).toHaveBeenCalledWith(NEON, 'setup'); - }); - - it('CREATE opens the new system there, on SETUP', async () => { - const onOpenBuilder = vi.fn(); - render(); - await folder('NEW'); - await userEvent.type(screen.getByLabelText('NAME'), 'Tidewater{Enter}'); - await waitFor(() => expect(onOpenBuilder).toHaveBeenCalledWith(COPY, 'setup')); - }); - - it('has no OPEN IN BUILDER without a builder to open', async () => { - open(); - await waitFor(() => expect(listed()).toHaveLength(3)); - expect(screen.queryByText('OPEN IN BUILDER')).toBeNull(); - }); -}); - -describe('NEW', () => { - it('makes a draft from a name, then shows it picked in SYSTEMS', async () => { - const told = vi.fn(); - window.addEventListener(SYSTEMS_CHANGED_EVENT, told); - open(); - await folder('NEW'); - expect((screen.getByText('CREATE') as HTMLButtonElement).disabled).toBe(true); - await userEvent.type(screen.getByLabelText('NAME'), ' Tidewater {Enter}'); - expect(calls.find((c) => c.method === 'POST')).toEqual({ url: '/api/systems', method: 'POST', body: { name: 'Tidewater' } }); - expect(await screen.findByText('Made Tidewater as a draft.')).toBeTruthy(); - expect(screen.getByRole('tab', { name: 'SYSTEMS' }).getAttribute('aria-selected')).toBe('true'); - await waitFor(() => expect(entry('Tidewater').getAttribute('aria-pressed')).toBe('true')); - expect(told).toHaveBeenCalledTimes(1); - window.removeEventListener(SYSTEMS_CHANGED_EVENT, told); - }); - - it('refuses a name in use where it was typed, keeping it to change', async () => { - open(); - await folder('NEW'); - await userEvent.type(screen.getByLabelText('NAME'), 'hearth'); - await userEvent.click(screen.getByText('CREATE')); - expect((await screen.findByRole('alert')).textContent).toBe('Another system is already called Hearth.'); - expect((screen.getByLabelText('NAME') as HTMLInputElement).value).toBe('hearth'); - expect(document.activeElement).toBe(screen.getByLabelText('NAME')); - await userEvent.type(screen.getByLabelText('NAME'), 'fire'); - expect(screen.queryByRole('alert')).toBeNull(); - }); - - it('starts blank only, for now', async () => { - open(); - await folder('NEW'); - const starts = within(screen.getByRole('radiogroup', { name: 'Start from' })).getAllByRole('radio') as HTMLButtonElement[]; - expect(starts.map((b) => [b.textContent!.split('A name')[0].split('Cities')[0].split('Fantasy')[0], b.disabled, b.getAttribute('aria-checked')])).toEqual([ - ['BLANK', false, 'true'], ['A BUILT-IN EXAMPLE', true, 'false'], ['A GENRE STARTER', true, 'false'], - ]); - }); -}); - -describe('INSTALL', () => { - /** The INSTALL button, not the INSTALL folder. */ - const installButton = () => screen.getAllByRole('button', { name: 'INSTALL' }).find((b) => b.getAttribute('role') !== 'tab')!; - const choose = async (text = '{"citysys":1}', name = 'iron-sea.citysys') => { - await userEvent.upload(screen.getByLabelText('System file'), new File([text], name, { type: 'application/json' })); - }; - - it('previews a file, then installs it, saying so', async () => { - open(); - await folder('INSTALL'); - await choose(); - const cover = await screen.findByTestId('install-cover'); - expect(calls.find((c) => c.url === '/api/systems/install/preview')!.body).toEqual({ file: '{"citysys":1}' }); - expect(within(cover).getByText('IRON SEA')).toBeTruthy(); - expect(within(cover).getByText('M. Okafor')).toBeTruthy(); - expect(within(cover).getByText('CITY_NET 1.15.0')).toBeTruthy(); - expect(within(cover).getByText('12 WORDS RENAMED')).toBeTruthy(); - expect(screen.getByText('iron-sea.citysys · CHOOSE ANOTHER FILE')).toBeTruthy(); - expect(screen.getByText('Not installed here. It installs as a new system, published and ready to run.')).toBeTruthy(); - await userEvent.click(installButton()); - expect(calls.find((c) => c.url === '/api/systems/install')!.body).toEqual({ file: '{"citysys":1}', mode: 'new' }); - expect(await screen.findByText('Installed Iron Sea v4. It\'s in the list and the game-system picker.')).toBeTruthy(); - expect(screen.queryByTestId('install-cover')).toBeNull(); - await userEvent.click(screen.getByRole('tab', { name: 'SYSTEMS' })); - expect(entry('Iron Sea').getAttribute('aria-pressed')).toBe('true'); - }); - - it('asks before REPLACING changes made here, and sends the say-so only then', async () => { - previewAnswer = { status: 200, body: { - ...PREVIEW, installed: [{ id: HEARTH, name: 'Iron Sea (ours)', version: 3, edited: true }], - installsAs: { new: null, update: 'Iron Sea (ours)', keep_both: 'Iron Sea copy' }, - } }; - open(); - await folder('INSTALL'); - await choose(); - await userEvent.click(await screen.findByText('REPLACE WITH v4')); - const ask = screen.getByRole('alertdialog', { name: 'REPLACE WITH v4' }); - expect(within(ask).getByText('Your changes to Iron Sea (ours) since you installed it will be lost. Characters, banks and tokens are kept.')).toBeTruthy(); - await userEvent.click(within(ask).getByText('CANCEL')); - expect(calls.some((c) => c.url === '/api/systems/install')).toBe(false); - await userEvent.click(screen.getByText('REPLACE WITH v4')); - await userEvent.click(within(screen.getByRole('alertdialog')).getByText('REPLACE WITH v4')); - expect(calls.find((c) => c.url === '/api/systems/install')!.body).toEqual({ file: '{"citysys":1}', mode: 'update', replaceChanges: true }); - }); - - it('keeps both without asking, and won\'t update with a file that has problems', async () => { - previewAnswer = { status: 200, body: { - ...PREVIEW, problems: [{ where: 'derived armor', message: 'Depends on itself' }], - installed: [{ id: HEARTH, name: 'Iron Sea', version: 3, edited: false }], - installsAs: { new: null, update: 'Iron Sea', keep_both: 'Iron Sea copy' }, - } }; - open(); - await folder('INSTALL'); - await choose(); - expect(await screen.findByText('derived armor: Depends on itself')).toBeTruthy(); - expect((screen.getByText('UPDATE TO v4') as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByText('UPDATE TO v4: The file has problems.')).toBeTruthy(); - await userEvent.click(screen.getByText('KEEP BOTH, AS A DRAFT')); - expect(calls.find((c) => c.url === '/api/systems/install')!.body).toEqual({ file: '{"citysys":1}', mode: 'keep_both' }); - }); - - it('says why a file can\'t be read, and refuses one far too large without sending it', async () => { - previewAnswer = { status: 400, body: { error: 'Not a CITY_NET system file' } }; - open(); - await folder('INSTALL'); - await choose('hello', 'notes.txt'); - expect((await screen.findByRole('alert')).textContent).toBe('Not a CITY_NET system file'); - expect(screen.queryByTestId('install-cover')).toBeNull(); - const before = calls.length; - await choose('x'.repeat(1024 * 1024 + 1), 'huge.citysys'); - expect((await screen.findByRole('alert')).textContent).toBe('huge.citysys is over 1 MB, too large to be a system file.'); - expect(calls.length).toBe(before); - }); - - it('shows a refused install where it happened', async () => { - installAnswer = { status: 409, body: { error: 'Already installed. Update it or keep both.' } }; - open(); - await folder('INSTALL'); - await choose(); - await screen.findByTestId('install-cover'); - await userEvent.click(installButton()); - expect((await screen.findByRole('alert')).textContent).toBe('Already installed. Update it or keep both.'); - expect(screen.getByTestId('install-cover')).toBeTruthy(); - }); -}); - -describe('the GAME tab', () => { - let pickerFetches = 0; - beforeEach(() => { - pickerFetches = 0; - vi.stubGlobal('fetch', vi.fn(async (url: string) => { - if (String(url) === '/api/sheets/system') pickerFetches += 1; - return { ok: true, json: async () => (String(url) === '/api/sheets/system' ? { system: 'generic', systems: [] } : []) }; - })); - }); - const props = (onOpenSystems?: () => void): any => ({ - socketRef: { current: { emit: vi.fn(), on: vi.fn(), off: vi.fn() } }, token: 'admintoken', onLogout: vi.fn(), - refreshLocations: vi.fn(), refreshRoads: vi.fn(), locations: [], roads: [], editData: {}, setEditData: vi.fn(), - editId: null, setEditId: vi.fn(), view: 'list', setView: vi.fn(), pendingRequests: [], setPendingRequests: vi.fn(), - selectedIds: [], setSelectedIds: vi.fn(), districts: [], fetchDistricts: vi.fn(), districtConfig: {}, setDistrictConfig: vi.fn(), - fetchGlobalSettings: vi.fn(), activeUsers: [], setIsAdminPayOpen: vi.fn(), handleSaveDefault: vi.fn(), handleLoadDefault: vi.fn(), - globalSettings: { game_system: 'generic' }, gameSystem: 'generic', onOpenSystems, - }); - const ttrpg = async (onOpenSystems?: () => void) => { - render(); - await userEvent.click(screen.getByText('GAME')); - await userEvent.click(screen.getByText(/TTRPG_SYSTEM/)); - }; - - it('opens SYSTEMS.EXE from one button under the picker', async () => { - const opened = vi.fn(); - await ttrpg(opened); - await userEvent.click(screen.getByText('SYSTEMS.EXE')); - expect(opened).toHaveBeenCalledTimes(1); - }); - - it('has no button for anyone but the main admin', async () => { - await ttrpg(undefined); - expect(screen.queryByText('SYSTEMS.EXE')).toBeNull(); - }); - - it('fetches the picker\'s list again when SYSTEMS.EXE changes a system', async () => { - await ttrpg(vi.fn()); - await waitFor(() => expect(pickerFetches).toBe(1)); - window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); - await waitFor(() => expect(pickerFetches).toBe(2)); - }); -}); diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts index 9783df29..95558cb5 100644 --- a/frontend/src/sheets/systemsApi.ts +++ b/frontend/src/sheets/systemsApi.ts @@ -1,4 +1,4 @@ -// SYSTEMS.EXE's requests to the server (4a1b2): backend/routes/systems.js, main admin only. Each +// The system builder's requests to the server (4a1b2): backend/routes/systems.js, main admin only. Each // answers { ok: true, value } or { ok: false, error }, the error being the server's own words // ("Another system is already called Ember.") so the window can show them where they apply. // What the answers mean is systemsLibrary.ts. diff --git a/frontend/src/sheets/systemsLibrary.ts b/frontend/src/sheets/systemsLibrary.ts index 52851913..91326a8a 100644 --- a/frontend/src/sheets/systemsLibrary.ts +++ b/frontend/src/sheets/systemsLibrary.ts @@ -1,4 +1,5 @@ -// What SYSTEMS.EXE says about a GM's own game systems (4a1b2): the badges in its list, the facts +// What the builder's MY SYSTEMS says about a GM's own game systems (4a1b2, first for the SYSTEMS.EXE +// window, retired in 4a2c2b): the badges in its list, the facts // beside the one picked, what an install preview offers, and the line after each action. Pure, so // the window only draws it. The server's answers are shaped by backend/systemBuilder/store.js // (listSystems, previewInstall, installSystem); its requests are in systemsApi.ts. @@ -80,7 +81,7 @@ export const changedFact = (updatedAt: string): string => { }; /** - * Sent on the window after SYSTEMS.EXE changes a system, so the game-system picker beside it + * Sent on the window after the builder changes a system, so the game-system picker * fetches the list again: a renamed, copied or deleted system shows there at once. */ export const SYSTEMS_CHANGED_EVENT = 'citynet:systems-changed';