Setzt Visualization.md Abschnitt 9, Phasen 3-5 vollstaendig um: Phase 3 (Kanban-Board live): Board zeigt echte Ticket-Daten aus GET /api/v1/tickets (office-api.js), Klick auf eine Spalte oeffnet ein Modal mit Titel/Beschreibung/Typ und einem Link auf die Codeberg-Quelle bei per Sync angelegten Tickets (F4). Phase 4 (Monitor-Wand live): Event-, System- und KPI-Monitor zeigen echte Daten, Klick-Modals fuer alle drei. Neuer SSE-Endpoint GET /api/v1/stream (F13) meldet per ChangeFeed-Fingerabdruck, welche Ressourcen sich geaendert haben; office-realtime.js nutzt EventSource mit Polling-Fallback. KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein Chart-Lib) sobald KPIs gemeldet wurden, sonst weiterhin der Phase-2-Platzhalter (F7 bleibt inhaltlich offen). API-Antworten tragen jetzt CORS-Header, da Frontend (:8090) und API (:8091) im Docker-Compose-Setup unterschiedliche Origins sind. Phase 5 (Agenten & Animation): resolveAgentVisualState bildet gemeldete Agenten-Zustaende auf Ort/Pose ab; Agenten-Avatare sind eine per CSS transition animierte, von Board/ Schreibtisch-Moebeln entkoppelte Schicht, die zwischen Schreibtisch, Board, Monitor-Wand und einer neuen Kaffee-Ecke laufen. Idle-Varianten (Zeitung/Kaffee/Schwaetzchen) werden rein kosmetisch client-seitig zufaellig ausgeloest (F8), eine AnimationQueue serialisiert Update-Animationen bei vielen gleichzeitigen Ereignissen (F9). 29 Node-Tests (100% Line-Coverage office-scene.js) und neue PHPUnit-Tests fuer Response::stream(), ChangeFeed, StreamController und die neuen Repository::fingerprint()- Methoden. PHP/Docker-Ausfuehrung konnte in dieser Umgebung nicht verifiziert werden (kein PHP verfuegbar) - vor dem Merge bitte `docker compose run --rm api composer test` pruefen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
312 lines
11 KiB
JavaScript
312 lines
11 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,
|
|
pickIdleActivity,
|
|
AnimationQueue,
|
|
} 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);
|
|
});
|