import test from "node:test"; import assert from "node:assert/strict"; import { BOARD_COLUMNS, buildBoardColumnLayout, buildDeskLayout, buildSystemTiles, buildEventLines, MAX_SYSTEM_TILES, MAX_EVENT_LINES, DEFAULT_DESK_COUNT, aggregateTicketsByColumn, groupTicketsByColumn, buildTicketModalContent, formatEventTime, mapEventForDisplay, buildEventModalContent, buildSystemModalContent, buildKpiSparklinePoints, buildKpiTiles, buildKpiModalContent, MAX_KPI_TILES, AGENT_LOCATIONS, resolveAgentVisualState, mapAgentsToDesks, normalizeAgentForDisplay, pickIdleActivity, AnimationQueue, DOOR_VISIT_TYPES, DOOR_VISIT_STAGES, detectDoorVisits, resolveDoorVisitVisual, advanceDoorVisitStage, } from "../../html/js/office-scene.js"; test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => { const layout = buildBoardColumnLayout({}); assert.deepEqual( layout.map((c) => c.key), BOARD_COLUMNS.map((c) => c.key) ); }); test("buildBoardColumnLayout zeigt Tickets bis einschliesslich 5 einzeln an", () => { const layout = buildBoardColumnLayout({ bereit: 5 }); const bereit = layout.find((c) => c.key === "bereit"); assert.equal(bereit.visibleNotes, 5); assert.equal(bereit.stackCount, 0); }); test("buildBoardColumnLayout stapelt ab dem 5. Ticket (F3): aelteste 4 einzeln + Rest als Stapel", () => { const layout = buildBoardColumnLayout({ backlog: 8 }); const backlog = layout.find((c) => c.key === "backlog"); assert.equal(backlog.visibleNotes, 4); assert.equal(backlog.stackCount, 4); assert.equal(backlog.count, 8); }); test("buildBoardColumnLayout stellt die Spalte done immer als Stapel dar (F2), unabhaengig von der Anzahl", () => { const wenige = buildBoardColumnLayout({ done: 2 }).find((c) => c.key === "done"); assert.equal(wenige.visibleNotes, 0); assert.equal(wenige.stackCount, 2); const viele = buildBoardColumnLayout({ done: 42 }).find((c) => c.key === "done"); assert.equal(viele.visibleNotes, 0); assert.equal(viele.stackCount, 42); }); test("buildBoardColumnLayout behandelt fehlende/negative Zaehler als 0", () => { const layout = buildBoardColumnLayout({ qa: -3 }); const qa = layout.find((c) => c.key === "qa"); assert.equal(qa.count, 0); assert.equal(qa.visibleNotes, 0); }); test("buildDeskLayout erzeugt genau DEFAULT_DESK_COUNT Schreibtische", () => { const desks = buildDeskLayout([]); assert.equal(desks.length, DEFAULT_DESK_COUNT); assert.ok(desks.every((d) => d.agent === null)); }); test("buildDeskLayout ordnet Agenten der Reihe nach Schreibtischen zu und ignoriert Ueberschuss", () => { const agents = Array.from({ length: DEFAULT_DESK_COUNT + 2 }, (_, i) => ({ name: `Agent${i}`, role: "entwickler", })); const desks = buildDeskLayout(agents); assert.equal(desks.length, DEFAULT_DESK_COUNT); assert.equal(desks[0].agent.name, "Agent0"); assert.equal(desks[DEFAULT_DESK_COUNT - 1].agent.name, `Agent${DEFAULT_DESK_COUNT - 1}`); }); test("buildDeskLayout ergaenzt fehlende pose auf idle", () => { const desks = buildDeskLayout([{ name: "Alice", role: "entwickler" }], 1); assert.equal(desks[0].agent.pose, "idle"); }); test("buildSystemTiles sortiert Fehler vor ok und kappt bei MAX_SYSTEM_TILES", () => { const systems = Array.from({ length: MAX_SYSTEM_TILES + 3 }, (_, i) => ({ id: `s${i}`, status: i === MAX_SYSTEM_TILES ? "error" : "ok", })); const tiles = buildSystemTiles(systems); assert.equal(tiles.length, MAX_SYSTEM_TILES); assert.equal(tiles[0].status, "error"); }); test("buildEventLines kappt bei MAX_EVENT_LINES und behaelt die Reihenfolge bei", () => { const events = Array.from({ length: MAX_EVENT_LINES + 4 }, (_, i) => ({ id: i })); const lines = buildEventLines(events); assert.equal(lines.length, MAX_EVENT_LINES); assert.equal(lines[0].id, 0); }); // --------------------------------------------------------------------------------------- // Phase 3 - Kanban-Board live // --------------------------------------------------------------------------------------- test("aggregateTicketsByColumn zaehlt Tickets je Spalte und ignoriert unbekannte Spalten", () => { const counts = aggregateTicketsByColumn([ { column_name: "backlog" }, { column_name: "backlog" }, { column_name: "qa" }, { column_name: "unbekannt" }, ]); assert.equal(counts.backlog, 2); assert.equal(counts.qa, 1); assert.equal(counts.done, 0); assert.equal(Object.keys(counts).length, BOARD_COLUMNS.length); }); test("groupTicketsByColumn gruppiert und behaelt die Reihenfolge je Spalte bei", () => { const groups = groupTicketsByColumn([ { id: 1, column_name: "backlog" }, { id: 2, column_name: "done" }, { id: 3, column_name: "backlog" }, ]); assert.deepEqual( groups.backlog.map((t) => t.id), [1, 3] ); assert.deepEqual( groups.done.map((t) => t.id), [2] ); assert.deepEqual(groups.qa, []); }); test("buildTicketModalContent traegt Titel/Spaltenlabel und baut Codeberg-Links nur fuer source=codeberg", () => { const content = buildTicketModalContent("bereit", [ { id: 1, title: "A", description: "", source: "codeberg", external_ref: "42" }, { id: 2, title: "B", description: "", source: "api", external_ref: null }, ]); assert.equal(content.title, "Bereit"); assert.equal(content.items[0].link, "https://codeberg.org/fds2610/monitor/issues/42"); assert.equal(content.items[1].link, null); }); test("buildTicketModalContent respektiert benutzerdefinierten owner/repo", () => { const content = buildTicketModalContent( "backlog", [{ id: 1, title: "A", source: "codeberg", external_ref: "7" }], { codebergOwner: "acme", codebergRepo: "widgets" } ); assert.equal(content.items[0].link, "https://codeberg.org/acme/widgets/issues/7"); }); // --------------------------------------------------------------------------------------- // Phase 4 - Monitor-Wand live // --------------------------------------------------------------------------------------- test("formatEventTime liefert HH:MM und faengt ungueltige Zeitstempel ab", () => { assert.match(formatEventTime("2026-07-27T10:05:00Z"), /^\d{2}:\d{2}$/); assert.equal(formatEventTime("nicht-valide"), "--:--"); }); test("mapEventForDisplay uebersetzt ein rohes Event in die Wand-Zeilendarstellung", () => { const line = mapEventForDisplay({ occurred_at: "2026-07-27T10:05:00Z", name: "Ping", message: "Host down", severity: "error", }); assert.equal(line.system, "Ping"); assert.equal(line.message, "Host down"); assert.equal(line.severity, "error"); assert.match(line.time, /^\d{2}:\d{2}$/); }); test("buildEventModalContent zeigt die volle Liste (kein MAX_EVENT_LINES-Limit)", () => { const events = Array.from({ length: MAX_EVENT_LINES + 3 }, (_, i) => ({ occurred_at: "2026-07-27T10:00:00Z", name: `Event${i}`, message: "m", severity: "warning", system_id: "srv1", })); const content = buildEventModalContent(events); assert.equal(content.items.length, events.length); assert.equal(content.items[0].systemId, "srv1"); }); test("buildSystemModalContent uebernimmt Status/Detail je System", () => { const content = buildSystemModalContent([ { id: "srv1", name: "Webserver", status: "ok", type: "host", detail: null, last_check_at: "t" }, ]); assert.equal(content.items[0].status, "ok"); assert.equal(content.items[0].detail, ""); }); test("buildKpiSparklinePoints liefert leeren String ohne Historie und einen Punkt pro Messwert sonst", () => { assert.equal(buildKpiSparklinePoints([]), ""); const points = buildKpiSparklinePoints([{ value: 3 }, { value: 1 }, { value: 2 }], 100, 20); assert.equal(points.split(" ").length, 3); }); test("buildKpiSparklinePoints behandelt konstante Werte ohne Division durch 0", () => { const points = buildKpiSparklinePoints([{ value: 5 }, { value: 5 }], 100, 20); assert.equal(points.split(" ").length, 2); assert.ok(points.length > 0); }); test("buildKpiTiles kappt bei MAX_KPI_TILES", () => { const kpis = Array.from({ length: MAX_KPI_TILES + 2 }, (_, i) => ({ key: `k${i}`, current_value: i, history: [], })); const tiles = buildKpiTiles(kpis); assert.equal(tiles.length, MAX_KPI_TILES); }); test("buildKpiModalContent zeigt alle KPIs (kein Limit) inkl. Historie", () => { const content = buildKpiModalContent([ { key: "open_tickets", label: "Offene Tickets", unit: "count", current_value: 4, history: [{ value: 4 }] }, ]); assert.equal(content.items.length, 1); assert.equal(content.items[0].label, "Offene Tickets"); }); // --------------------------------------------------------------------------------------- // Phase 5 - Agenten-Zustandsmaschine & Animation // --------------------------------------------------------------------------------------- test("resolveAgentVisualState bildet bekannte Zustaende auf Ort/Pose ab", () => { assert.deepEqual(resolveAgentVisualState("working"), { location: AGENT_LOCATIONS.DESK, pose: "working" }); assert.deepEqual(resolveAgentVisualState("walking_to_board"), { location: AGENT_LOCATIONS.BOARD, pose: "walking", }); assert.deepEqual(resolveAgentVisualState("at_coffee_corner"), { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee", }); }); test("resolveAgentVisualState faellt bei unbekanntem/fehlendem Zustand auf idle-am-Schreibtisch zurueck", () => { assert.deepEqual(resolveAgentVisualState("was-auch-immer"), { location: AGENT_LOCATIONS.DESK, pose: "idle" }); assert.deepEqual(resolveAgentVisualState(undefined), { location: AGENT_LOCATIONS.DESK, pose: "idle" }); }); test("mapAgentsToDesks platziert Agenten anhand von desk_index, nicht Array-Reihenfolge", () => { const desks = mapAgentsToDesks( [ { id: 1, desk_index: 2 }, { id: 2, desk_index: 0 }, ], 4 ); assert.equal(desks[0].id, 2); assert.equal(desks[2].id, 1); assert.equal(desks[1], null); assert.equal(desks[3], null); }); test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rolle user)", () => { const desks = mapAgentsToDesks([{ id: 1, desk_index: null, role: "user" }], 2); assert.ok(desks.every((d) => d === null)); }); test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => { const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" }); assert.equal(normalized.name, "Alice"); assert.equal(normalized.pose, "working"); assert.equal(normalized.location, AGENT_LOCATIONS.DESK); }); test("pickIdleActivity nutzt den injizierten Zufallsgenerator deterministisch", () => { assert.equal(pickIdleActivity(() => 0), "reading_newspaper"); assert.equal(pickIdleActivity(() => 0.999), "talking_to_colleague"); }); test("AnimationQueue fuehrt Tasks mit Mindestabstand aus (Warteschlange bei Buerst-Events, F9)", () => { let now = 0; const queue = new AnimationQueue({ minIntervalMs: 100, now: () => now }); const ran = []; queue.enqueue(() => ran.push("a")); queue.enqueue(() => ran.push("b")); queue.enqueue(() => ran.push("c")); queue.processDue(); assert.deepEqual(ran, ["a"]); assert.equal(queue.size, 2); now = 50; queue.processDue(); assert.deepEqual(ran, ["a"]); now = 120; queue.processDue(); assert.deepEqual(ran, ["a", "b"]); now = 500; queue.processDue(); assert.deepEqual(ran, ["a", "b", "c"]); assert.equal(queue.size, 0); }); // --------------------------------------------------------------------------------------- // Phase 6 - Externe Rollen & Tueren (F2) // --------------------------------------------------------------------------------------- test("detectDoorVisits erkennt ein neues, per Codeberg-Sync angelegtes Backlog-Ticket", () => { const visits = detectDoorVisits([], [{ id: 1, column_name: "backlog", source: "codeberg" }]); assert.deepEqual(visits, [{ type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: 1 }]); }); test("detectDoorVisits ignoriert neue Backlog-Tickets, die nicht per Codeberg-Sync kamen", () => { const visits = detectDoorVisits([], [{ id: 1, column_name: "backlog", source: "api" }]); assert.deepEqual(visits, []); }); test("detectDoorVisits ignoriert neue Tickets ausserhalb von Backlog (von einer Rolle angelegt)", () => { const visits = detectDoorVisits([], [{ id: 1, column_name: "bereit", source: "api" }]); assert.deepEqual(visits, []); }); test("detectDoorVisits erkennt ein Ticket, das die Spalte fertig erreicht", () => { const before = [{ id: 5, column_name: "qa", source: "api" }]; const after = [{ id: 5, column_name: "fertig", source: "api" }]; assert.deepEqual(detectDoorVisits(before, after), [ { type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: 5 }, ]); }); test("detectDoorVisits erkennt ein Ticket, das geschlossen (done) wird", () => { const before = [{ id: 7, column_name: "testing", source: "api" }]; const after = [{ id: 7, column_name: "done", source: "api" }]; assert.deepEqual(detectDoorVisits(before, after), [{ type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: 7 }]); }); test("detectDoorVisits ignoriert Tickets ohne Spaltenwechsel", () => { const before = [{ id: 1, column_name: "in_arbeit", source: "api" }]; const after = [{ id: 1, column_name: "in_arbeit", source: "api" }]; assert.deepEqual(detectDoorVisits(before, after), []); }); test("detectDoorVisits verarbeitet mehrere gleichzeitige Anlaesse in einem Diff", () => { const before = [ { id: 1, column_name: "qa", source: "api" }, { id: 2, column_name: "testing", source: "api" }, ]; const after = [ { id: 1, column_name: "fertig", source: "api" }, { id: 2, column_name: "done", source: "api" }, { id: 3, column_name: "backlog", source: "codeberg" }, ]; const visits = detectDoorVisits(before, after); assert.equal(visits.length, 3); assert.deepEqual(visits, [ { type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: 1 }, { type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: 2 }, { type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: 3 }, ]); }); test("resolveDoorVisitVisual bildet jede Stage auf Ort/Pose ab", () => { assert.deepEqual(resolveDoorVisitVisual("walking_to_board"), { location: AGENT_LOCATIONS.BOARD, pose: "walking", }); assert.deepEqual(resolveDoorVisitVisual("interacting_with_board"), { location: AGENT_LOCATIONS.BOARD, pose: "interacting", }); assert.deepEqual(resolveDoorVisitVisual("leaving"), { location: AGENT_LOCATIONS.DOOR, pose: "walking" }); }); test("resolveDoorVisitVisual faellt bei unbekannter Stage auf die erste Stage zurueck", () => { assert.deepEqual(resolveDoorVisitVisual("unbekannt"), resolveDoorVisitVisual(DOOR_VISIT_STAGES[0])); }); test("advanceDoorVisitStage durchlaeuft alle Stages und liefert am Ende null", () => { let stage = DOOR_VISIT_STAGES[0]; const seen = [stage]; for (let i = 0; i < DOOR_VISIT_STAGES.length; i++) { stage = advanceDoorVisitStage(stage); if (stage !== null) seen.push(stage); } assert.deepEqual(seen, DOOR_VISIT_STAGES); assert.equal(advanceDoorVisitStage(DOOR_VISIT_STAGES[DOOR_VISIT_STAGES.length - 1]), null); }); test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => { assert.equal(advanceDoorVisitStage("unbekannt"), null); });