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';