diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 46e019a2..caeeafc2 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -89,7 +89,7 @@ export const de = { "leitstand.tab.classic": "Klassisch", "leitstand.tab.tools": "Werkzeuge", "leitstand.tab.betrieb": "Betrieb", - "leitstand.tab.fluss": "Fluss", + "leitstand.tab.fabrik": "Fabrik", "leitstand.profilesFailed": "Agenten konnten nicht geladen werden", "leitstand.profilesHint": "Die Karten zeigen vorerst die Profil-Kennung.", "leitstand.agents": "Agenten", diff --git a/src/screens/leitstand/LeitstandRoute.test.tsx b/src/screens/leitstand/LeitstandRoute.test.tsx index 4e4ff203..6afb1ba3 100644 --- a/src/screens/leitstand/LeitstandRoute.test.tsx +++ b/src/screens/leitstand/LeitstandRoute.test.tsx @@ -138,21 +138,21 @@ describe("Leitstand route behind D1", () => { expect(screen.queryByRole("region", { name: "Warteschlange" })).toBeNull(); }); - it("renders the flow graph at /leitstand/fluss and marks its tab as the current one", async () => { + it("renders the flow graph at /leitstand/fabrik and marks its tab as the current one", async () => { localStorage.setItem(FLAG_KEY, "1"); - window.location.hash = "#/leitstand/fluss"; + window.location.hash = "#/leitstand/fabrik"; const { container } = render(); const flow = await screen.findByRole("region", { name: "Ablauf der Agenten" }); expect(await within(flow).findByRole("group", { name: "Fix the start block" })).toBeInTheDocument(); - expect(tabs().getByRole("link", { name: "Fluss" })).toHaveAttribute("aria-current", "page"); + expect(tabs().getByRole("link", { name: "Fabrik" })).toHaveAttribute("aria-current", "page"); expect(tabs().getByRole("link", { name: "Haupt" })).not.toHaveAttribute("aria-current"); expect(screen.queryByRole("region", { name: "Agenten" })).toBeNull(); expect(container.querySelector(".g-mount-legacy")).toHaveAttribute("hidden"); }); - it("shows the empty hint at /leitstand/fluss while no agent runs", async () => { + it("shows the empty hint at /leitstand/fabrik while no agent runs", async () => { localStorage.setItem(FLAG_KEY, "1"); - window.location.hash = "#/leitstand/fluss"; + window.location.hash = "#/leitstand/fabrik"; const base = currentInvoke(); mocks.invoke.mockImplementation((command: string) => command === "get_board_state" ? Promise.resolve({ cards: [], coordinators: [] }) : base(command)); @@ -161,9 +161,9 @@ describe("Leitstand route behind D1", () => { expect(container.querySelector(".fl-node")).toBeNull(); }); - it("does not claim an empty flow when the board cannot be read at /leitstand/fluss", async () => { + it("does not claim an empty flow when the board cannot be read at /leitstand/fabrik", async () => { localStorage.setItem(FLAG_KEY, "1"); - window.location.hash = "#/leitstand/fluss"; + window.location.hash = "#/leitstand/fabrik"; const base = currentInvoke(); mocks.invoke.mockImplementation((command: string) => command === "get_board_state" ? Promise.reject(new Error("backend down")) : base(command)); @@ -172,15 +172,50 @@ describe("Leitstand route behind D1", () => { expect(screen.queryByText("Noch keine Agenten im Ablauf")).toBeNull(); }); - it("reaches Fluss from Haupt by its tab link", async () => { + it("reaches Fabrik from Haupt by its tab link", async () => { localStorage.setItem(FLAG_KEY, "1"); render(); await screen.findByRole("region", { name: "Agenten" }); - fireEvent.click(tabs().getByRole("link", { name: "Fluss" })); - await waitFor(() => expect(window.location.hash).toBe("#/leitstand/fluss")); + expect(tabs().getAllByRole("link").map((link) => link.textContent)).toEqual([ + "Haupt", "Fabrik", "Betrieb", "Werkzeuge", "Klassisch", + ]); + expect(tabs().getByRole("link", { name: "Fabrik" })).toHaveAttribute("href", "#/leitstand/fabrik"); + fireEvent.click(tabs().getByRole("link", { name: "Fabrik" })); + await waitFor(() => expect(window.location.hash).toBe("#/leitstand/fabrik")); expect(await screen.findByRole("region", { name: "Ablauf der Agenten" })).toBeInTheDocument(); }); + it("renders old flow paths as Fabrik immediately without requesting Haupt questions", async () => { + for (const path of ["/leitstand/fluss", "/leitstand/fluss/"]) { + mocks.invoke.mockClear(); + const view = render(); + expect.soft(tabs().getByRole("link", { name: "Fabrik" })).toHaveAttribute("aria-current", "page"); + expect.soft(screen.queryByRole("region", { name: "Agenten" })).toBeNull(); + expect.soft(screen.queryByRole("complementary", { name: "Braucht dich" })).toBeNull(); + await act(async () => {}); + expect.soft(mocks.invoke).not.toHaveBeenCalledWith("list_questions", expect.anything()); + expect.soft(screen.queryByRole("region", { name: "Ablauf der Agenten" })).toBeInTheDocument(); + view.unmount(); + } + }); + + it("redirects old flow bookmarks and later hash navigation to Fabrik", async () => { + window.location.hash = "#/leitstand/fluss"; + const { container } = render(); + const expectFabrik = async () => { + await waitFor(() => expect(window.location.hash).toBe("#/leitstand/fabrik")); + const flow = await screen.findByRole("region", { name: "Ablauf der Agenten" }); + expect(await within(flow).findByRole("group", { name: "Fix the start block" })).toBeInTheDocument(); + expect(tabs().getByRole("link", { name: "Fabrik" })).toHaveAttribute("aria-current", "page"); + expect(container.querySelector(".g-mount-legacy")).toHaveAttribute("hidden"); + }; + await expectFabrik(); + await go("#/leitstand/betrieb"); + await screen.findByRole("region", { name: "Warteschlange" }); + await go("#/leitstand/fluss/"); + await expectFabrik(); + }); + // COMP-1-3: the node wears the card's lamp, still without proof. const flowBoard = (...cards: Array<{ w: typeof worker; column: string }>) => { const base = currentInvoke(); @@ -192,7 +227,7 @@ describe("Leitstand route behind D1", () => { it("shows the unproven lamp on a done node", async () => { localStorage.setItem(FLAG_KEY, "1"); - window.location.hash = "#/leitstand/fluss"; + window.location.hash = "#/leitstand/fabrik"; flowBoard({ w: worker, column: "done" }); render(); const node = await screen.findByRole("group", { name: "Fix the start block" }); @@ -203,7 +238,7 @@ describe("Leitstand route behind D1", () => { it("gives each lamp kind its atom on a node", async () => { localStorage.setItem(FLAG_KEY, "1"); - window.location.hash = "#/leitstand/fluss"; + window.location.hash = "#/leitstand/fabrik"; flowBoard( { w: { ...worker, id: "w1", task: "Runs now" }, column: "working" }, { w: { ...worker, id: "w2", task: "Waits for you" }, column: "needs_you" }, @@ -223,21 +258,21 @@ describe("Leitstand route behind D1", () => { expect(tabs().getByRole("link", { name: "Betrieb" })).toHaveAttribute("aria-current", "page"); }); - it("shows the Fluss tab as active for a trailing-slash path", async () => { - render(); + it("shows the Fabrik tab as active for a trailing-slash path", async () => { + render(); expect(await screen.findByRole("region", { name: "Ablauf der Agenten" })).toBeInTheDocument(); - expect(tabs().getByRole("link", { name: "Fluss" })).toHaveAttribute("aria-current", "page"); + expect(tabs().getByRole("link", { name: "Fabrik" })).toHaveAttribute("aria-current", "page"); expect(screen.queryByRole("region", { name: "Agenten" })).toBeNull(); }); - it("keeps /leitstand/fluss off when D1 is explicitly disabled", async () => { + it("keeps /leitstand/fabrik off when D1 is explicitly disabled", async () => { localStorage.setItem(FLAG_KEY, "0"); - window.location.hash = "#/leitstand/fluss"; + window.location.hash = "#/leitstand/fabrik"; const { container } = render(); await waitFor(() => expect(container.querySelector(".app")).not.toBeNull()); expect(container.querySelector(".g-mount")).toBeNull(); expect(screen.queryByRole("region", { name: "Ablauf der Agenten" })).toBeNull(); - expect(screen.queryByRole("link", { name: "Fluss" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Fabrik" })).toBeNull(); }); it("offers no Betrieb route while the switch is off", async () => { diff --git a/src/screens/leitstand/LeitstandRoute.tsx b/src/screens/leitstand/LeitstandRoute.tsx index 98600b73..b8576f90 100644 --- a/src/screens/leitstand/LeitstandRoute.tsx +++ b/src/screens/leitstand/LeitstandRoute.tsx @@ -12,30 +12,29 @@ import { LeitstandCards } from "./LeitstandCards"; import { BetriebPanel } from "./betrieb/BetriebPanel"; import { FlowTab } from "./flow/FlowTab"; import { NeedsYou } from "./needs/NeedsYou"; -import { TOOLS_PATH, trim } from "../../shell/mount/routing"; +import { FABRIK_PATH, TOOLS_PATH, trim } from "../../shell/mount/routing"; export const MAIN_PATH = "/leitstand"; export const CLASSIC_PATH = "/leitstand/klassisch"; export const BETRIEB_PATH = "/leitstand/betrieb"; -export const FLUSS_PATH = "/leitstand/fluss"; /** Relative ages ("seit 4 min") move on minute scale. */ const TICK_MS = 30_000; -/** The classic view and its persistent tools stay reachable beside Haupt, Fluss and Betrieb. */ +/** The classic view and its persistent tools stay reachable beside Haupt, Fabrik and Betrieb. */ export function LeitstandTabs({ path }: { path: string }) { const t = useT(); const tab = (target: string, label: string) => ( {label} ); - return ; + return ; } -/** The new Leitstand (V2-S01a): agent cards with filter chips and "Braucht dich" on Haupt, the flow graph on Fluss, the read-only Betrieb panel beside it. */ +/** The new Leitstand (V2-S01a): agent cards with filter chips and "Braucht dich" on Haupt, the flow graph on Fabrik, the read-only Betrieb panel beside it. */ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) { const here = trim(path); const betrieb = here === BETRIEB_PATH; - const fluss = here === FLUSS_PATH; + const fabrik = here === FABRIK_PATH || here === "/leitstand/fluss"; const t = useT(); const [projectId, setProjectId] = useState(readActiveProjectId); // The picker and the old app publish a choice through the module's event. @@ -44,7 +43,7 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) return subscribeActiveProject(setProjectId); }, []); const board = useBoard(projectId, !betrieb); - const questions = useQuestions(projectId, !betrieb && !fluss); + const questions = useQuestions(projectId, !betrieb && !fabrik); const [profiles, setProfiles] = useState([]); const [profilesFailed, setProfilesFailed] = useState(false); const [now, setNow] = useState(() => Date.now() / 1000); @@ -71,10 +70,10 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) ); } - if (fluss) { + if (fabrik) { return (
-

{t("nav.leitstand")}

+

{t("nav.leitstand")}

{/* An unread or unreadable board must not claim "no agents". */} {board.loading ? null : board.error !== null && board.cards.length === 0 ?
diff --git a/src/screens/leitstand/flow/FlowTab.tsx b/src/screens/leitstand/flow/FlowTab.tsx index 4b692c9f..ffb08b93 100644 --- a/src/screens/leitstand/flow/FlowTab.tsx +++ b/src/screens/leitstand/flow/FlowTab.tsx @@ -65,7 +65,7 @@ function FlowNodeBody({ label, lamp, quota, proof }: { label: FlowLabel; lamp?: ); } -/** The Fluss tab: the flow graph of the board's agents, one lamp, one quota reading and the proof strip on every node. */ +/** The Fabrik tab: the flow graph of the board's agents, one lamp, one quota reading and the proof strip on every node. */ export function FlowTab({ cards }: { cards: readonly BoardCard[] }) { const [reads, setReads] = useState({ profiles: null, quotas: null, budgets: null }); useEffect(() => { diff --git a/src/shell/mount/ShellMount.tsx b/src/shell/mount/ShellMount.tsx index b40d1195..abc2c87f 100644 --- a/src/shell/mount/ShellMount.tsx +++ b/src/shell/mount/ShellMount.tsx @@ -8,7 +8,7 @@ import StartRoute from "../../screens/start/StartRoute"; import TerminalRoute from "../../screens/terminal/TerminalRoute"; import LeitstandRoute, { CLASSIC_PATH, LeitstandTabs } from "../../screens/leitstand/LeitstandRoute"; import { EinstellungenScreen } from "../../screens/einstellungen/EinstellungenScreen"; -import { SETTINGS_CLASSIC_PATH, hasLegacyPanel, hashForGoal, isNewEinstellungen, isNewLeitstand, isNewSteuerung, isStart, isTerminal } from "./routing"; +import { FABRIK_PATH, SETTINGS_CLASSIC_PATH, hasLegacyPanel, hashForGoal, isNewEinstellungen, isNewLeitstand, isNewSteuerung, isStart, isTerminal } from "./routing"; import SteuerungRoute from "./SteuerungRoute"; import "./mount.css"; @@ -40,7 +40,15 @@ export default function ShellMount({ children }: { children: (props: LegacyRoute return () => watch.disconnect(); }, []); useEffect(() => { - const onHash = () => setPath(pathFromHash(window.location.hash)); + const onHash = () => { + const next = pathFromHash(window.location.hash); + if (next === "/leitstand/fluss") { + // Replace the bookmark so Back does not revisit the retired tab URL. + window.location.replace(`#${FABRIK_PATH}`); + setPath(FABRIK_PATH); + } else setPath(next); + }; + onHash(); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); diff --git a/src/shell/mount/routing.test.ts b/src/shell/mount/routing.test.ts index 02e0cab1..caec3596 100644 --- a/src/shell/mount/routing.test.ts +++ b/src/shell/mount/routing.test.ts @@ -24,6 +24,15 @@ describe("goal and route mapping (ia.md section 4)", () => { } }); + it("keeps Fabrik and the old flow alias out of the legacy terminal viewer", () => { + for (const path of ["/leitstand/fabrik", "/leitstand/fabrik/", "/leitstand/fluss", "/leitstand/fluss/"]) { + expect(isNewLeitstand(path)).toBe(true); + expect(shouldMountLegacyTerminal(path)).toBe(false); + } + expect(isNewLeitstand("/leitstand/fabrik/unknown")).toBe(false); + expect(isNewLeitstand("/leitstand/fluss/unknown")).toBe(false); + }); + it("keeps the legacy goal when entering the terminal route", () => { for (const path of ["/leitstand/terminal", "/leitstand/terminal/"]) { expect(goalForPath(path, "review")).toBe("review"); diff --git a/src/shell/mount/routing.ts b/src/shell/mount/routing.ts index 21052193..73f72a37 100644 --- a/src/shell/mount/routing.ts +++ b/src/shell/mount/routing.ts @@ -15,13 +15,15 @@ const base = (path: string) => `/${path.split("/")[1] ?? ""}`; export const trim = (path: string) => path.replace(/\/+$/, ""); +export const FABRIK_PATH = "/leitstand/fabrik"; + export const TOOLS_PATH = "/leitstand/werkzeuge"; export const isTools = (path: string) => trim(path) === TOOLS_PATH; export const isTerminal = (path: string) => trim(path.split("?")[0]) === "/leitstand/terminal"; -/** The new Leitstand owns `/leitstand` (V2-S01a), `/leitstand/betrieb` (V2-S01b) and `/leitstand/fluss` (COMP-3-3); the rest below it stays the old view. */ -export const isNewLeitstand = (path: string) => ["/leitstand", "/leitstand/betrieb", "/leitstand/fluss"].includes(trim(path)); +/** The new Leitstand owns `/leitstand` (V2-S01a), `/leitstand/betrieb` (V2-S01b) and `/leitstand/fabrik` with its old `/leitstand/fluss` alias; the rest below it stays the old view. */ +export const isNewLeitstand = (path: string) => ["/leitstand", "/leitstand/betrieb", FABRIK_PATH, "/leitstand/fluss"].includes(trim(path)); /** The new Steuerung (V2-S05a) owns `/steuerung` and its tabs: Kontingente plus two honest placeholders. */ export const isNewSteuerung = (path: string) => base(trim(path)) === "/steuerung";