From e96b2e18435f98d67afc62966a2cad705e950a67 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 29 Jul 2026 17:02:49 +0200 Subject: [PATCH 1/2] Agent-Praesenz mit Zehn-Minuten-Timeout versehen Ein registrierter Agent gilt im Bueromonitor nur noch als anwesend, solange last_seen_at hoechstens zehn Minuten zurueckliegt (isAgentPresent, office-scene.js). Meldet er sich laenger nicht, bleibt sein Schreibtisch frei und er erscheint auch nicht mehr im Ueberlauf-Ruheplatz vor der Tuer, statt weiterhin faelschlich als anwesend dargestellt zu werden. Schliesst #17 --- html/js/office-render.js | 2 ++ html/js/office-scene.js | 33 +++++++++++++++++++++++++++-- tests/frontend/office-scene.test.js | 32 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/html/js/office-render.js b/html/js/office-render.js index ab60e9e..deda218 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -27,6 +27,7 @@ import { mapAgentsToDesks, normalizeAgentForDisplay, resolveAgentVisualState, + isAgentPresent, pickIdleActivity, AnimationQueue, detectDoorVisits, @@ -1003,6 +1004,7 @@ function agentPlacements(agents, deskLayout) { let overflowIndex = 0; for (const raw of agents) { if (seatedIds.has(raw.id)) continue; + if (!isAgentPresent(raw)) continue; const agent = normalizeAgentForDisplay(raw); const homeFeet = { x: 70 + (overflowIndex % 3) * 26, diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 32d2337..5cbebb6 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -355,20 +355,49 @@ export function resolveAgentVisualState(state) { return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle; } +// Praesenz-Timeout (Issue #17): ein registrierter Agent gilt nur noch als anwesend, solange +// last_seen_at nicht laenger als zehn Minuten zurueckliegt. Meldet er sich laenger nicht, +// bleibt sein Schreibtisch/Platz leer, obwohl er weiterhin registriert ist. +export const AGENT_PRESENCE_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * @param {{last_seen_at?: string}} agent + * @param {number} [now] - Referenzzeitpunkt in ms seit Epoch (injizierbar fuer Tests). + * @returns {boolean} true, wenn der Agent als anwesend gilt. Fehlt last_seen_at (z.B. + * Demo-Daten ohne API-Felder) oder ist es nicht parsbar, gilt der Agent defensiv als + * anwesend, statt faelschlich einen leeren Platz zu zeigen. + */ +export function isAgentPresent(agent, now = Date.now()) { + if (!agent || !agent.last_seen_at) { + return true; + } + const lastSeenAt = new Date(agent.last_seen_at).getTime(); + if (Number.isNaN(lastSeenAt)) { + return true; + } + return now - lastSeenAt <= AGENT_PRESENCE_TIMEOUT_MS; +} + /** * Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als * buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge, * sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user" * oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen - * gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. + * gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die + * laenger als AGENT_PRESENCE_TIMEOUT_MS nicht mehr gesehen wurden (last_seen_at, Issue #17), + * erscheinen ebenfalls nicht - ihr Schreibtisch bleibt frei. * * @param {Array} agents * @param {number} [deskCount] + * @param {number} [now] - Referenzzeitpunkt fuer den Praesenz-Check (injizierbar fuer Tests). * @returns {Array} */ -export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) { +export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT, now = Date.now()) { const desks = new Array(deskCount).fill(null); for (const agent of agents) { + if (!isAgentPresent(agent, now)) { + continue; + } const deskIndex = agent.desk_index; if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) { desks[deskIndex] = agent; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index c5e3bda..15e0b05 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -24,6 +24,8 @@ import { resolveAgentVisualState, mapAgentsToDesks, normalizeAgentForDisplay, + isAgentPresent, + AGENT_PRESENCE_TIMEOUT_MS, pickIdleActivity, AnimationQueue, DOOR_VISIT_TYPES, @@ -281,6 +283,36 @@ test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rol 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"); From 811a7d9f69d004604a9718a2c5d450e508909dff Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 29 Jul 2026 17:13:59 +0200 Subject: [PATCH 2/2] 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 --- html/js/office-render.js | 118 +++++++++++++++++++++++++--- html/js/office-scene.js | 65 +++++++++++++++ tests/frontend/office-scene.test.js | 75 ++++++++++++++++++ 3 files changed, 245 insertions(+), 13 deletions(-) diff --git a/html/js/office-render.js b/html/js/office-render.js index deda218..fe98c27 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -34,6 +34,11 @@ import { resolveDoorVisitVisual, advanceDoorVisitStage, DOOR_VISIT_STAGES, + detectUnassignedTicketChanges, + advancePoVisitStage, + PO_VISIT_STAGES, + PO_AGENT_ID, + PO_DISPLAY_NAME, resolveReloadIntervalSeconds, nextCountdownSeconds, } from "./office-scene.js"; @@ -62,6 +67,11 @@ const DESK_ROW_Y = [540, 840]; const DESK_COL_X = [232, 644, 1056, 1468]; const COFFEE_CORNER = { x: 960, y: 780 }; +// Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters +// (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten +// Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten. +const PO_DESK = { x: 870, y: 380, w: 180 }; + const NOTE_W = 96; const NOTE_H = 50; const NOTE_STEP_Y = 56; @@ -74,6 +84,9 @@ const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 }; // Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der // Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()). const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 }; +// Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der +// regulaeren Schreibtische (deskSeat().feet). +const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 }; const IDLE_MIN_DELAY_MS = 15000; const IDLE_MAX_DELAY_MS = 40000; @@ -429,16 +442,17 @@ function deskScreenPoseClass(pose) { // Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche // Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/ // updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu -// zeichnen zu muessen. -function renderDesk({ deskIndex, agent }) { - const { origin, feet } = deskSeat(deskIndex); +// zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren +// Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe +// Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen. +function renderDeskAt(origin, agent, width = DESK.w) { const role = agent ? ROLES[agent.role] : null; const g = el("g", { class: `desk ${agent ? "desk-occupied" : "desk-empty"}`, transform: `translate(${origin.x}, ${origin.y})`, }); - g.appendChild(el("rect", { x: 0, y: 40, width: DESK.w, height: DESK.h, rx: 6, class: "desk-table" })); + g.appendChild(el("rect", { x: 0, y: 40, width, height: DESK.h, rx: 6, class: "desk-table" })); g.appendChild( el("rect", { x: 20, @@ -449,18 +463,18 @@ function renderDesk({ deskIndex, agent }) { class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`, }) ); - g.appendChild(el("circle", { cx: DESK.w / 2, cy: feet.y - origin.y, r: 22, class: "desk-chair" })); + g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" })); if (role) { if (role.deco === "star") { g.appendChild( el("path", { - d: "M170,20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z", + d: `M${width - 50},20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z`, class: "desk-deco desk-deco-star", }) ); } else if (role.deco === "flower") { - const cx = 175; + const cx = width - 45; const cy = 22; g.appendChild( el("g", { class: "desk-deco desk-deco-flower" }, [ @@ -473,13 +487,13 @@ function renderDesk({ deskIndex, agent }) { ); } else if (role.deco === "check") { g.appendChild( - el("path", { d: "M162,24 l8,8 16,-18", class: "desk-deco desk-deco-check" }) + el("path", { d: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" }) ); } g.appendChild( text(agent.name, { - x: DESK.w / 2, + x: width / 2, y: 40 + DESK.h + 74, class: "desk-name", "text-anchor": "middle", @@ -488,7 +502,7 @@ function renderDesk({ deskIndex, agent }) { } else { g.appendChild( text("frei", { - x: DESK.w / 2, + x: width / 2, y: 40 + DESK.h + 74, class: "desk-name desk-name-empty", "text-anchor": "middle", @@ -499,9 +513,21 @@ function renderDesk({ deskIndex, agent }) { return g; } -function renderDesks(desks) { +function renderDesk({ deskIndex, agent }) { + return renderDeskAt(deskSeat(deskIndex).origin, agent); +} + +/** + * @param {Array} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1). + * @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer + * API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19). + */ +function renderDesks(desks, poAgent) { const g = el("g", { class: "desks" }); desks.forEach((desk) => g.appendChild(renderDesk(desk))); + const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w); + poDeskGroup.classList.add("po-desk"); + g.appendChild(poDeskGroup); return g; } @@ -719,10 +745,63 @@ function updateDoorActiveClass() { function repaintAgentsLayer() { if (!currentAgentsLayer) return; - updateAgentsLayer(currentAgentsLayer, [...currentRegularPlacements, ...doorVisitorPlacements()]); + updateAgentsLayer(currentAgentsLayer, [ + ...currentRegularPlacements, + ...poPlacement(), + ...doorVisitorPlacements(), + ]); updateDoorActiveClass(); } +// --------------------------------------------------------------------------------------- +// Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten" +// Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in +// office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt +// es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map. +// --------------------------------------------------------------------------------------- + +let poIsLive = false; +let poState = "idle"; +let poVisitTimeoutId = null; + +function poPlacement() { + if (!poIsLive) { + return []; + } + const visual = resolveAgentVisualState(poState); + return [ + { + agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location }, + homeFeet: PO_DESK_FEET, + }, + ]; +} + +function advancePoVisit() { + const nextStage = advancePoVisitStage(poState); + if (nextStage === null) { + poState = "idle"; + repaintAgentsLayer(); + return; + } + poState = nextStage; + repaintAgentsLayer(); + poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); +} + +// Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz +// hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer +// jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende +// animiert, statt unterbrochen zu werden. +function triggerPoVisit() { + if (!poIsLive || poState !== "idle") { + return; + } + poState = PO_VISIT_STAGES[0]; + repaintAgentsLayer(); + poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); +} + function advanceDoorVisit(id) { const visitor = doorVisitors.get(id); if (!visitor) { @@ -902,6 +981,7 @@ export function renderOfficeScene(container, data = {}) { events = DEMO_EVENTS, kpis = [], tickets = [], + isLive = false, } = data; let svg = container.querySelector(".office-svg"); @@ -922,8 +1002,15 @@ export function renderOfficeScene(container, data = {}) { idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId)); idleCosmeticTimers.clear(); doorVisitors.clear(); + if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId); + poVisitTimeoutId = null; + poState = "idle"; } + // Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber + // last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten (Issue #19). + poIsLive = isLive; + const groupedTickets = groupTicketsByColumn(tickets); const boardGroup = renderBoard(ticketCounts, (columnKey) => { @@ -954,7 +1041,8 @@ export function renderOfficeScene(container, data = {}) { } const deskLayout = buildDeskLayout(normalizedDeskAgents(agents)); - const desksGroup = renderDesks(deskLayout); + const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null; + const desksGroup = renderDesks(deskLayout, poDeskAgent); const existingDesks = svg.querySelector(".desks"); if (existingDesks) { existingDesks.replaceWith(desksGroup); @@ -1063,6 +1151,9 @@ async function boot() { const data = await loadLiveData(); if (previousTickets) { detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit); + if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) { + triggerPoVisit(); + } } previousTickets = data.tickets; renderOfficeScene(container, { @@ -1072,6 +1163,7 @@ async function boot() { systems: data.systems, events: data.events, kpis: data.kpis, + isLive: true, }); setApiBanner(false); } catch (err) { diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 5cbebb6..dee9a0b 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -568,6 +568,71 @@ export function advanceDoorVisitStage(stage) { return DOOR_VISIT_STAGES[index + 1]; } +// --------------------------------------------------------------------------------------- +// Product Owner (Issue #19): fester Platz + Board-Besuch bei Ticket-Statuswechseln, die noch +// keiner Rolle zugeordnet sind (Visualization.md Abschnitt 4.4/9, Analogon zu Phase 6/Tueren). +// --------------------------------------------------------------------------------------- + +// Der PO ist keine echte Registrierung ueber POST /api/v1/agents, sondern repraesentiert die +// Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen +// Schreibtisch aus dem 8er-Raster, sondern einen eigenen, festen Platz (siehe office-render.js, +// PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber +// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten, siehe isAgentPresent()). +export const PO_AGENT_ID = "po"; +export const PO_DISPLAY_NAME = "PO"; + +// Statuswechsel, die bereits einer anderen Rolle zugeordnet sind: neue Backlog-Tickets sowie +// der Wechsel nach "fertig"/"done" loesen bereits einen Tuerbesuch der Rolle "user" aus +// (siehe DOOR_VISIT_TYPES). Alles andere - z.B. der Wechsel nach "bereit" durch das Label +// "automate" (das genau dieses Issue #19 ausgeloest hat) - ist bislang niemandem zugeordnet +// und damit Sache des PO. +const PO_UNASSIGNED_EXCLUDED_COLUMNS = new Set(["fertig", "done"]); + +/** + * Erkennt Ticket-Statuswechsel, die keiner Rolle zugeordnet sind (Gegenstueck zu + * detectDoorVisits() fuer die bereits zugeordneten Faelle). Neu angelegte Tickets zaehlen hier + * nicht mit - die sind bereits Sache von detectDoorVisits()/NEW_BACKLOG_TICKET. + * + * @param {Array<{id: number, column_name: string}>} previousTickets + * @param {Array<{id: number, column_name: string}>} currentTickets + * @returns {Array<{ticketId: number, column: string}>} + */ +export function detectUnassignedTicketChanges(previousTickets = [], currentTickets = []) { + const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket])); + const changes = []; + + for (const ticket of currentTickets) { + const before = previousById.get(ticket.id); + if (!before || before.column_name === ticket.column_name) { + continue; + } + if (PO_UNASSIGNED_EXCLUDED_COLUMNS.has(ticket.column_name)) { + continue; + } + changes.push({ ticketId: ticket.id, column: ticket.column_name }); + } + + return changes; +} + +// Ablauf eines PO-Board-Besuchs: vom festen Platz zum Board laufen, dort kurz interagieren, +// zurueck zum Platz. Nutzt bewusst dieselben state-Keys wie die Agenten-Zustandsmaschine +// (resolveAgentVisualState), damit keine zweite Ort/Pose-Zuordnung noetig ist. +export const PO_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "walking_back"]; + +/** + * @param {string} stage - aktuelle Stage aus PO_VISIT_STAGES. + * @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist (der PO + * ist danach wieder "idle" am eigenen Platz). + */ +export function advancePoVisitStage(stage) { + const index = PO_VISIT_STAGES.indexOf(stage); + if (index === -1 || index === PO_VISIT_STAGES.length - 1) { + return null; + } + return PO_VISIT_STAGES[index + 1]; +} + // --------------------------------------------------------------------------------------- // Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die // Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 15e0b05..4c42731 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -33,6 +33,11 @@ import { detectDoorVisits, resolveDoorVisitVisual, advanceDoorVisitStage, + PO_AGENT_ID, + PO_DISPLAY_NAME, + PO_VISIT_STAGES, + detectUnassignedTicketChanges, + advancePoVisitStage, DEFAULT_RELOAD_INTERVAL_SECONDS, resolveReloadIntervalSeconds, nextCountdownSeconds, @@ -440,6 +445,76 @@ 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);