import { test } from "node:test"; import assert from "node:assert/strict"; import { presentingNames, teamSize, targetSections, distributePresenters, assignPresenters, DIVIDER_TYPES } from "../src/ai/team.js"; const team = (members) => ({ team: { label: "G1", members } }); test("presentingNames returns the presenting members, in order", () => { const id = team([ { name: "A", presenting: true }, { name: "B", presenting: false }, { name: "C", presenting: true }, ]); assert.deepEqual(presentingNames(id), ["A", "C"]); }); test("presentingNames falls back to every named member when nobody is ticked", () => { const id = team([ { name: "A", presenting: false }, { name: "B", presenting: false }, ]); assert.deepEqual(presentingNames(id), ["A", "B"]); }); test("presentingNames ignores the placeholder empty-name members of a fresh identity", () => { const id = team([ { name: "", presenting: true }, { name: "A", presenting: true }, { name: "", presenting: false }, ]); assert.deepEqual(presentingNames(id), ["A"]); }); test("teamSize counts named members, never the padding placeholders", () => { assert.equal(teamSize(team([{ name: "A" }, { name: "B" }])), 2); assert.equal(teamSize(team([{ name: "" }, { name: "" }])), 1); assert.equal(teamSize({}), 1); }); test("targetSections floors small teams at 3 with no ceiling", () => { assert.equal(targetSections(team([{ name: "A" }, { name: "B" }])), 3); assert.equal(targetSections(team([{ name: "A" }, { name: "B" }, { name: "C" }])), 3); assert.equal(targetSections(team([{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }])), 4); assert.equal(targetSections(team([{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }, { name: "E" }])), 5); assert.equal(targetSections(team([{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }, { name: "E" }, { name: "F" }, { name: "G" }, { name: "H" }])), 8); const eleven = Array.from({ length: 11 }, (_, i) => ({ name: `M${i}` })); assert.equal(targetSections(team(eleven)), 11); }); test("targetSections honours a custom floor — a report's graded core", () => { assert.equal(targetSections(team([{ name: "A" }]), { min: 4 }), 4); assert.equal(targetSections(team([{ name: "A" }, { name: "B" }, { name: "C" }]), { min: 4 }), 4); assert.equal(targetSections(team([{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }]), { min: 4 }), 4); }); /* ---------------------------------------------- presenter distribution */ // A helper deck: interleave dividers with content so the returned assignment // can be checked for both contiguity and divider-avoidance. function deckOf(contentBySection) { const slides = [{ type: "title", section: 0 }]; contentBySection.forEach((content, sec) => { slides.push({ type: "section", section: sec }); for (const type of content) slides.push({ type, section: sec }); }); slides.push({ type: "closing", section: contentBySection.length - 1 }); return slides; } const presenterAt = (assignment) => assignment.filter(Boolean); const blocks = (assignment) => { // A member's block is contiguous in deck order: the section dividers that // open/close it carry no presenter, so contiguity means the presenter's runs // over the CONTENT sequence (dividers stripped) are uninterrupted. const content = assignment.filter(Boolean); const out = []; let cur = null; for (const p of content) { if (cur && cur.name === p) cur.count++; else { if (cur) out.push(cur); cur = { name: p, count: 1 }; } } if (cur) out.push(cur); return out; }; test("5 members / 5 sections → each member presents their own section", () => { const slides = deckOf([["bullets", "bullets"], ["bullets"], ["bullets"], ["bullets"], ["bullets"]]); const a = distributePresenters(slides, ["M1", "M2", "M3", "M4", "M5"]); // Dividers carry nothing: title and every section divider are null. assert.equal(a[0], null); assert.equal(a[1], null); assert.deepEqual(a.slice(-1), [null]); // closing divider const b = blocks(a); assert.equal(b.length, 5, "five members, five contiguous blocks"); assert.deepEqual(b.map((x) => x.name), ["M1", "M2", "M3", "M4", "M5"]); assert.equal(b[0].count, 2, "section 0 carries two slides"); }); test("3 members / 8 content slides → balanced contiguous blocks", () => { // Sections of [3, 3, 2] → members get 3, 3, 2 slides, each one block. const slides = deckOf([["bullets", "bullets", "bullets"], ["bullets", "bullets", "bullets"], ["bullets", "bullets"]]); const a = distributePresenters(slides, ["A", "B", "C"]); const b = blocks(a); assert.deepEqual(b.map((x) => [x.name, x.count]), [["A", 3], ["B", 3], ["C", 2]]); // Balance: block sizes differ by at most one. const counts = b.map((x) => x.count).sort((p, q) => p - q); assert.ok(counts[counts.length - 1] - counts[0] <= 1); }); test("a section is never split between two members", () => { const slides = deckOf([["bullets", "bullets"], ["bullets", "bullets", "bullets"], ["bullets"]]); const a = distributePresenters(slides, ["A", "B"]); // Section 0 (2 bullets, indices 2,3) is wholly A's; section 1 (indices 5-7) // and section 2 (index 9) are wholly B's — no section straddles a boundary. assert.equal(a[2], "A"); assert.equal(a[3], "A"); for (const i of [5, 6, 7, 9]) assert.equal(a[i], "B", `slide ${i} should be B's`); assert.equal(blocks(a).length, 2, "two members, two blocks"); }); test("fewer members than sections: no one gets 2+ while another gets 0", () => { const slides = deckOf([["bullets"], ["bullets"], ["bullets"], ["bullets"]]); const a = distributePresenters(slides, ["A", "B", "C"]); const counts = blocks(a).map((x) => x.count); // 4 content slides across 3 members → 2,1,1 — nobody idle, no one doubled up // while another has nothing. assert.deepEqual(counts.sort((p, q) => p - q), [1, 1, 2]); }); test("0 presenting members → no presenters", () => { const slides = deckOf([["bullets", "bullets"], ["bullets"]]); assert.deepEqual(distributePresenters(slides, []), slides.map(() => null)); }); test("divider-only deck → no presenters", () => { const slides = [ { type: "title", section: 0 }, { type: "section", section: 0 }, { type: "chapter", section: 0 }, { type: "closing", section: 0 }, ]; assert.deepEqual(distributePresenters(slides, ["A", "B"]), [null, null, null, null]); }); test("a slidesPerMember override still produces contiguous blocks", () => { const slides = deckOf([["bullets"], ["bullets"], ["bullets"], ["bullets"], ["bullets"], ["bullets"]]); const a = distributePresenters(slides, ["A", "B", "C"], { slidesPerMember: 2 }); const b = blocks(a); assert.deepEqual(b.map((x) => [x.name, x.count]), [["A", 2], ["B", 2], ["C", 2]]); }); test("members ≥ sections: 11 members × 1-per-person → each presents exactly one slide", () => { // 7 sections, 11 content slides, 11 members, briefing "1 per person". The // old code assigned section i → member i and silently stranded members 8-11; // the fix hands the 11 content slides to 11 distinct members. const slides = deckOf([ ["bullets", "bullets"], ["bullets", "bullets"], ["bullets", "bullets"], ["bullets"], ["bullets"], ["bullets"], ["bullets", "bullets"], ]); const names = ["M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8", "M9", "M10", "M11"]; const a = distributePresenters(slides, names, { slidesPerMember: 1 }); const counts = names.map((name) => a.filter((p) => p === name).length); assert.deepEqual(counts, names.map(() => 1), "every member exactly one slide"); for (const p of names) assert.ok(a.includes(p), `every member presents at least once (${p})`); // Dividers still carry nothing. for (const [i, s] of slides.entries()) { if (DIVIDER_TYPES.has(s.type)) assert.equal(a[i], null, `divider at ${i} must be null`); } }); test("members ≥ sections: 11 members / 7 sections / 20 content slides → all covered, balance ≤1", () => { // 20 slides cannot fit 11 people at one each; the surplus must distribute at // balance ≤1 (nine members × 2, two × 1) and nobody may sit at zero while // another holds two. const slides = deckOf([ ["bullets", "bullets", "bullets", "bullets"], ["bullets", "bullets", "bullets", "bullets"], ["bullets", "bullets", "bullets"], ["bullets", "bullets", "bullets"], ["bullets", "bullets", "bullets"], ["bullets", "bullets", "bullets"], ]); const names = ["M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8", "M9", "M10", "M11"]; const a = distributePresenters(slides, names, { slidesPerMember: 1 }); const counts = names.map((name) => a.filter((p) => p === name).length); for (const c of counts) assert.ok(c >= 1, `every member ≥1 (got ${counts.join(",")})`); assert.ok(Math.max(...counts) - Math.min(...counts) <= 1, `balance ≤1 (got ${counts.join(",")})`); assert.ok(Math.max(...counts) <= 2, "surplus distributes at one extra, never a pile-on"); const content = a.filter(Boolean); const runs = blocks(a).map((b) => b.name); assert.equal(runs.length, names.length, "each member's slides are one contiguous block"); }); test("members ≥ sections: 5 members / 3 sections → contiguous blocks covering all 5", () => { // The old code gave sections to members 0-2 and left members 3-4 silent; the // fix splits a section's slides between adjacent members so all 5 present. const slides = deckOf([["bullets", "bullets"], ["bullets", "bullets"], ["bullets", "bullets"]]); const a = distributePresenters(slides, ["A", "B", "C", "D", "E"]); const counts = ["A", "B", "C", "D", "E"].map((name) => a.filter((p) => p === name).length); for (const c of counts) assert.ok(c >= 1, `every member ≥1 (got ${counts.join(",")})`); const b = blocks(a); assert.equal(b.length, 5, "five members, five contiguous blocks"); assert.deepEqual(b.map((x) => x.name), ["A", "B", "C", "D", "E"]); assert.ok(Math.max(...counts) - Math.min(...counts) <= 1, "balance ≤1"); }); test("dividers are never assigned a presenter", () => { const slides = deckOf([["bullets"], ["bullets"], ["bullets"]]); const a = distributePresenters(slides, ["A", "B", "C"]); for (const [i, s] of slides.entries()) { if (DIVIDER_TYPES.has(s.type)) assert.equal(a[i], null, `divider at ${i} must be null`); } }); /* ---------------------------------------------- assignPresenters (shared step) */ test("assignPresenters writes the split onto a real deck and strips divider strays", () => { const slides = deckOf([["bullets", "bullets"], ["bullets"], ["bullets"]]); const deck = { title: "t", sections: ["s0", "s1", "s2"], slides: structuredClone(slides) }; // A stray divider presenter from a model slip must be scrubbed. deck.slides[1].presenter = "A"; assignPresenters(deck, team([{ name: "A", presenting: true }, { name: "B", presenting: true }, { name: "C", presenting: true }])); for (const [i, s] of deck.slides.entries()) { if (DIVIDER_TYPES.has(s.type)) assert.equal(s.presenter, undefined, `divider ${i} carries nothing`); else assert.ok(s.presenter, `content slide ${i} has a presenter`); } const content = deck.slides.filter((s) => !DIVIDER_TYPES.has(s.type)).map((s) => s.presenter); assert.deepEqual(content, ["A", "A", "B", "C"], "three members, one contiguous block each"); }); test("assignPresenters honours slidesPerMember and is idempotent", () => { const slides = deckOf([["bullets", "bullets"], ["bullets", "bullets"], ["bullets"]]); const deck = { slides: structuredClone(slides) }; const id = team([{ name: "A", presenting: true }, { name: "B", presenting: true }, { name: "C", presenting: true }]); assignPresenters(deck, id, 1); const first = deck.slides.map((s) => s.presenter ?? null); // Re-running on an already-assigned deck is a no-op — every member's blocks // are contiguous and identical to the first pass. assignPresenters(deck, id, 1); const second = deck.slides.map((s) => s.presenter ?? null); assert.deepEqual(second, first, "assignment is stable under re-application"); const counts = ["A", "B", "C"].map((n) => first.filter((p) => p === n).length); assert.deepEqual(counts, [2, 2, 1]); }); test("assignPresenters with no presenting members leaves every slide untouched", () => { const slides = deckOf([["bullets", "bullets"]]); const deck = { slides: structuredClone(slides) }; assignPresenters(deck, team([{ name: "", presenting: true }])); assert.ok(deck.slides.every((s) => s.presenter === undefined)); }); test("owners map whole sections to members — one member may own several", () => { const slides = deckOf([["bullets"], ["bullets", "bullets"], ["bullets"], ["bullets"]]); const out = distributePresenters(slides, ["A", "B"], { owners: ["A", "A", "B", "B"] }); assert.deepEqual(presenterAt(out), ["A", "A", "A", "B", "B"]); }); test("owners fall back deterministically for a stale name", () => { const slides = deckOf([["bullets"], ["bullets"]]); const out = distributePresenters(slides, ["A", "B"], { owners: ["Z", "B"] }); assert.deepEqual(presenterAt(out), ["A", "B"]); }); test("reference slides are never assigned and never break contiguity", () => { const slides = [ { type: "title" }, { type: "bullets", section: 0 }, { type: "references", section: 0, headline: "References", items: ["A — used on slide 2"] }, { type: "closing", section: 0 }, ]; const deck = { slides: structuredClone(slides) }; const id = team([{ name: "A", presenting: true }]); assignPresenters(deck, id); assert.equal(deck.slides[2].presenter, undefined); assert.equal(deck.slides[1].presenter, "A"); });