monitor/tests/frontend/office-scene.test.js
fds2610-bot 811a7d9f69 PO bekommt festen Platz + laeuft bei niemandem zugeordneten Ticketwechseln zum Board
Der Product Owner ("PO") ist keine ueber die API registrierte Rolle, sondern
repraesentiert die Produktverantwortung des Systems selbst: eigener, fester
Platz ausserhalb des dynamischen 8er-Schreibtisch-Rasters, anwesend genau
dann, wenn die API erreichbar ist (statt ueber last_seen_at). Ausserdem laeuft
er zum Board, sobald sich dort ein Ticket-Status aendert, der noch keiner
anderen Rolle zugeordnet ist (z.B. Wechsel nach "bereit" durchs Label
"automate", wie es diesem Issue selbst passiert ist) - Wechsel nach
fertig/done bleiben weiterhin Sache der Rolle "user" (Tuerbesuche).

Schliesst #19
2026-07-29 17:13:59 +02:00

536 lines
20 KiB
JavaScript

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,
isAgentPresent,
AGENT_PRESENCE_TIMEOUT_MS,
pickIdleActivity,
AnimationQueue,
DOOR_VISIT_TYPES,
DOOR_VISIT_STAGES,
detectDoorVisits,
resolveDoorVisitVisual,
advanceDoorVisitStage,
PO_AGENT_ID,
PO_DISPLAY_NAME,
PO_VISIT_STAGES,
detectUnassignedTicketChanges,
advancePoVisitStage,
DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
} 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("isAgentPresent gilt als anwesend, solange last_seen_at hoechstens zehn Minuten zurueckliegt (Issue #17)", () => {
const now = Date.parse("2026-07-29T12:00:00Z");
assert.equal(
isAgentPresent({ last_seen_at: "2026-07-29T11:50:00Z" }, now),
true,
"genau zehn Minuten alt gilt noch als anwesend"
);
assert.equal(
isAgentPresent({ last_seen_at: "2026-07-29T11:49:59Z" }, now),
false,
"aelter als zehn Minuten gilt nicht mehr als anwesend"
);
});
test("isAgentPresent faellt bei fehlendem/kaputtem last_seen_at defensiv auf anwesend zurueck", () => {
assert.equal(isAgentPresent({ id: 1 }), true);
assert.equal(isAgentPresent({ id: 1, last_seen_at: "nicht-parsbar" }), true);
});
test("mapAgentsToDesks laesst den Schreibtisch eines Agenten frei, der laenger als zehn Minuten nicht gesehen wurde", () => {
const now = Date.parse("2026-07-29T12:00:00Z");
const staleSeenAt = new Date(now - AGENT_PRESENCE_TIMEOUT_MS - 1000).toISOString();
const desks = mapAgentsToDesks(
[{ id: 1, desk_index: 0, last_seen_at: staleSeenAt }],
2,
now
);
assert.equal(desks[0], 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);
});
// ---------------------------------------------------------------------------------------
// Product Owner (Issue #19)
// ---------------------------------------------------------------------------------------
test("PO_AGENT_ID/PO_DISPLAY_NAME sind stabile Konstanten (der PO heisst schlicht 'PO')", () => {
assert.equal(PO_AGENT_ID, "po");
assert.equal(PO_DISPLAY_NAME, "PO");
});
test("detectUnassignedTicketChanges erkennt einen Statuswechsel nach 'bereit' (Label automate)", () => {
const before = [{ id: 19, column_name: "backlog" }];
const after = [{ id: 19, column_name: "bereit" }];
assert.deepEqual(detectUnassignedTicketChanges(before, after), [{ ticketId: 19, column: "bereit" }]);
});
test("detectUnassignedTicketChanges ignoriert Wechsel nach fertig/done (schon Sache der Rolle user)", () => {
const before = [
{ id: 1, column_name: "qa" },
{ id: 2, column_name: "testing" },
];
const after = [
{ id: 1, column_name: "fertig" },
{ id: 2, column_name: "done" },
];
assert.deepEqual(detectUnassignedTicketChanges(before, after), []);
});
test("detectUnassignedTicketChanges ignoriert neu angelegte Tickets (schon Sache von detectDoorVisits)", () => {
const visits = detectUnassignedTicketChanges([], [{ id: 1, column_name: "bereit" }]);
assert.deepEqual(visits, []);
});
test("detectUnassignedTicketChanges ignoriert Tickets ohne Spaltenwechsel", () => {
const before = [{ id: 1, column_name: "in_arbeit" }];
const after = [{ id: 1, column_name: "in_arbeit" }];
assert.deepEqual(detectUnassignedTicketChanges(before, after), []);
});
test("detectUnassignedTicketChanges verarbeitet mehrere gleichzeitige Wechsel", () => {
const before = [
{ id: 1, column_name: "backlog" },
{ id: 2, column_name: "backlog" },
{ id: 3, column_name: "qa" },
];
const after = [
{ id: 1, column_name: "bereit" },
{ id: 2, column_name: "in_arbeit" },
{ id: 3, column_name: "fertig" },
];
assert.deepEqual(detectUnassignedTicketChanges(before, after), [
{ ticketId: 1, column: "bereit" },
{ ticketId: 2, column: "in_arbeit" },
]);
});
test("advancePoVisitStage durchlaeuft alle Stages und liefert am Ende null", () => {
let stage = PO_VISIT_STAGES[0];
const seen = [stage];
for (let i = 0; i < PO_VISIT_STAGES.length; i++) {
stage = advancePoVisitStage(stage);
if (stage !== null) seen.push(stage);
}
assert.deepEqual(seen, PO_VISIT_STAGES);
assert.equal(advancePoVisitStage(PO_VISIT_STAGES[PO_VISIT_STAGES.length - 1]), null);
});
test("advancePoVisitStage liefert bei unbekannter Stage null", () => {
assert.equal(advancePoVisitStage("unbekannt"), null);
});
test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => {
assert.equal(resolveReloadIntervalSeconds(30), 30);
assert.equal(resolveReloadIntervalSeconds("45"), 45);
assert.equal(resolveReloadIntervalSeconds(12.4), 12);
});
test("resolveReloadIntervalSeconds faellt bei fehlendem/ungueltigem Wert auf den Default zurueck", () => {
assert.equal(resolveReloadIntervalSeconds(undefined), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds(null), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds(0), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds(-5), DEFAULT_RELOAD_INTERVAL_SECONDS);
assert.equal(resolveReloadIntervalSeconds("nicht-numerisch"), DEFAULT_RELOAD_INTERVAL_SECONDS);
});
test("nextCountdownSeconds zaehlt herunter und springt bei 1 auf das volle Intervall zurueck", () => {
assert.equal(nextCountdownSeconds(30, 30), 29);
assert.equal(nextCountdownSeconds(2, 30), 1);
assert.equal(nextCountdownSeconds(1, 30), 30);
});