Files
PaperClipAI/ui/src/lib/attention.test.ts
T
TonioandClaude Opus 5 40e7add71c test(ui): make the suite independent of the machine timezone (#11478)
Several suites asserted local-time renders from UTC instants, or built date
fixtures from the real clock, so they only held where local time happened to
match. CI runs in UTC and never reported it; a contributor anywhere else saw
failures on a clean checkout.

Seven files, anchored to the clock under test rather than to the machine's.
The set grew twice while being fixed: the two tests the issue named surfaced
three more at UTC+9, and those surfaced two more at UTC+14.

`StatusCards/format` is the interesting one. `rollupUpdatesToday` filters on the
UTC calendar day to match the server token cap, while the fixtures were built
on the local day. West of UTC that lands `iso(0)` in the previous UTC day for
the stretch between UTC midnight and local midnight - about seven hours a day
at UTC-7 - and east of UTC+12 "today at local noon" is already yesterday in UTC
outright. Either way the rows it means to count drop out. A run crossing
midnight UTC splits the same way.

Fixes #11476.

Deliberately left: IssueProperties.test.tsx:1515-1517 still pin the minute of
three timestamps against a UTC fixture. They pass at every offset tried,
including UTC+5:45, and the minute there is load-bearing - it distinguishes
Created from Started from Completed - so it wants more care than mechanical
anchoring.

Full ui suite 4113 pass. The TZ pin added by #11480 is still in place here and
is now redundant; #11508 removes it, stacked on this branch so it cannot land
without the anchoring it depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:22:45 -07:00

659 lines
25 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
import {
ATTENTION_GROUP_BY_KEY,
ATTENTION_GROUP_BY_OPTIONS,
attentionBadgeCount,
attentionDateBucket,
attentionDetailLine,
attentionIsNewToday,
attentionKind,
attentionStatus,
attentionTaskRef,
buildAttentionFilterOptions,
buildDeskShelves,
countActiveAttentionFilters,
defaultAttentionFilterState,
filterAttentionItems,
groupAttentionItems,
isInlineResolvable,
loadAttentionGroupBy,
NO_GROUP_SENTINEL,
planAttentionRenderRows,
saveAttentionGroupBy,
severityStyle,
sortAttentionItems,
sourceMeta,
} from "./attention";
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
return {
id: "a1",
companyId: "c1",
sourceKind: "approval",
subject: { kind: "approval", id: "s1", companyId: "c1", title: "t", identifier: null, status: null, href: null },
whyNow: "why",
decisionVerbs: [],
inlineResolvable: true,
entryRule: "",
exitRule: "",
dedupKey: "d1",
dismissalKey: "attention:d1",
severity: "medium",
rank: 0,
activityAt: "2026-07-09T12:00:00Z",
createdAt: "2026-07-09T12:00:00Z",
updatedAt: "2026-07-09T12:00:00Z",
relatedIssue: null,
project: null,
workspace: null,
expiresAt: null,
ruleKey: null,
originAgentName: null,
queues: [],
shelf: false,
retentionDays: 30,
keep: false,
archivedAt: null,
retentionVersion: 1,
decideBy: null,
decideByAttribution: null,
snoozedUntil: null,
detail: null,
dismissal: null,
...overrides,
trainingExampleId: overrides.trainingExampleId ?? null,
};
}
describe("attention group preference persistence", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to None and lists it as the first group option", () => {
expect(loadAttentionGroupBy()).toBe("none");
expect(ATTENTION_GROUP_BY_OPTIONS[0]).toEqual(["none", "None"]);
});
it("round-trips explicit grouped choices and treats stale values as None", () => {
saveAttentionGroupBy("date");
expect(loadAttentionGroupBy()).toBe("date");
localStorage.setItem(ATTENTION_GROUP_BY_KEY, "unexpected");
expect(loadAttentionGroupBy()).toBe("none");
});
});
describe("isInlineResolvable", () => {
it("is true for approvals/interactions/join when server flags inlineResolvable", () => {
for (const kind of ["approval", "issue_thread_interaction", "join_request"] as AttentionSourceKind[]) {
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(true);
}
});
it("is false when the server marks a row non-inline (e.g. board approval)", () => {
expect(isInlineResolvable(buildItem({ sourceKind: "approval", inlineResolvable: false }))).toBe(false);
});
it("inlines a stalled review the server flagged (PAP-16080 §4.4)", () => {
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: true }))).toBe(true);
});
it("keeps a covered review deep-linking (server leaves inlineResolvable off)", () => {
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: false }))).toBe(false);
});
it("deep-links recovery/failure/budget rows rather than inlining", () => {
for (const kind of ["recovery_action", "failed_run", "budget_alert", "blocker_attention"] as AttentionSourceKind[]) {
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(false);
}
});
});
describe("attentionBadgeCount", () => {
it("uses the server's pre-pagination desk badge count", () => {
const feed: AttentionFeed = {
companyId: "c1",
generatedAt: "2026-07-09T12:00:00Z",
totalCount: 3,
deskBadgeCount: 2,
nextCursor: "next-page",
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })],
};
expect(attentionBadgeCount(feed)).toBe(2);
});
it("is zero for an empty or missing feed", () => {
expect(attentionBadgeCount(null)).toBe(0);
expect(attentionBadgeCount(undefined)).toBe(0);
});
});
// Desk grouping — arrival-based ("New today" / "Earlier") with a "Decide now"
// shelf only when an explicit decide-by deadline is due.
describe("buildDeskShelves", () => {
const NOW = Date.parse("2026-07-09T12:00:00Z");
const todayIso = "2026-07-09T09:00:00Z";
const earlierIso = "2026-07-01T09:00:00Z";
it("groups by arrival with no shelf when nothing has a due deadline", () => {
const items = [
buildItem({ id: "new-1", createdAt: todayIso }),
buildItem({ id: "old-1", createdAt: earlierIso }),
buildItem({ id: "new-2", createdAt: "2026-07-09T02:00:00Z" }),
];
const shelves = buildDeskShelves(items, NOW);
expect(shelves.map((s) => s.key)).toEqual(["desk:new-today", "desk:earlier"]);
expect(shelves[0]!.label).toBe("New today");
expect(shelves[0]!.items.map((i) => i.id).sort()).toEqual(["new-1", "new-2"]);
expect(shelves[1]!.items.map((i) => i.id)).toEqual(["old-1"]);
});
it("adds the 'Decide now' shelf only for items with a due decide-by, and never double-buckets them", () => {
const items = [
buildItem({ id: "due", decideBy: "today", createdAt: todayIso }),
buildItem({ id: "overdue", decideBy: "2026-07-01", createdAt: earlierIso }),
buildItem({ id: "new", createdAt: "2026-07-09T05:00:00Z" }),
buildItem({ id: "old", createdAt: earlierIso }),
buildItem({ id: "whenever", decideBy: "whenever", createdAt: "2026-07-09T11:00:00Z" }),
];
const shelves = buildDeskShelves(items, NOW);
expect(shelves.map((s) => s.key)).toEqual(["desk:decide-now", "desk:new-today", "desk:earlier"]);
// Decide-now items are pulled out of the arrival groups (disjoint shelves).
expect(shelves[0]!.items.map((i) => i.id)).toEqual(["overdue", "due"]);
// "New today" is newest-arrival-first: whenever (11:00) before new (05:00).
expect(shelves[1]!.items.map((i) => i.id)).toEqual(["whenever", "new"]);
expect(shelves[2]!.items.map((i) => i.id)).toEqual(["old"]);
// Every item lands in exactly one shelf.
const total = shelves.reduce((n, s) => n + s.items.length, 0);
expect(total).toBe(items.length);
});
it("returns no shelves for an empty desk", () => {
expect(buildDeskShelves([], NOW)).toEqual([]);
});
});
describe("attentionIsNewToday", () => {
const NOW = Date.parse("2026-07-09T12:00:00Z");
it("is true when the item surfaced on the current UTC day", () => {
expect(attentionIsNewToday(buildItem({ createdAt: "2026-07-09T00:00:01Z" }), NOW)).toBe(true);
expect(attentionIsNewToday(buildItem({ createdAt: "2026-07-08T23:59:59Z" }), NOW)).toBe(false);
});
});
describe("sourceMeta + severityStyle", () => {
it("labels every catalog source kind", () => {
const kinds: AttentionSourceKind[] = [
"approval",
"decision",
"issue_thread_interaction",
"join_request",
"recovery_action",
"productivity_review",
"blocker_attention",
"review",
"failed_run",
"budget_alert",
"agent_error_alert",
];
for (const kind of kinds) {
expect(sourceMeta(kind).label.length).toBeGreaterThan(0);
}
});
it("maps escalation severity to distinct accents", () => {
expect(severityStyle("critical").accent).not.toBe(severityStyle("low").accent);
});
});
// Supersedes the five-tone map (sky/violet/rose/amber/neutral) + severityBadge:
// rows now resolve to one of two kinds, each borrowing a task status for its
// colour and glyph, so the queue and the task list share one vocabulary.
describe("attentionKind + attentionStatus (flattened decision types)", () => {
it("reads anything stuck as blocking", () => {
expect(attentionKind(buildItem({ sourceKind: "failed_run" }))).toBe("blocking");
expect(attentionKind(buildItem({ sourceKind: "agent_error_alert" }))).toBe("blocking");
expect(attentionKind(buildItem({ sourceKind: "blocker_attention" }))).toBe("blocking");
expect(attentionKind(buildItem({ sourceKind: "recovery_action" }))).toBe("blocking");
expect(attentionKind(buildItem({ sourceKind: "budget_alert" }))).toBe("blocking");
});
it("reads anything awaiting a verdict as review", () => {
expect(attentionKind(buildItem({ sourceKind: "approval" }))).toBe("review");
expect(attentionKind(buildItem({ sourceKind: "issue_thread_interaction" }))).toBe("review");
expect(attentionKind(buildItem({ sourceKind: "join_request" }))).toBe("review");
expect(attentionKind(buildItem({ sourceKind: "review" }))).toBe("review");
expect(attentionKind(buildItem({ sourceKind: "productivity_review" }))).toBe("review");
});
it("keeps plan approvals in the review family whichever surface raised them", () => {
const planApproval = (): AttentionItem["detail"] => ({
kind: "plan_approval",
issueTitle: "I",
planTitle: "P",
summaryExcerpt: null,
images: [],
});
expect(attentionStatus(buildItem({ sourceKind: "approval", detail: planApproval() }))).toBe("in_review");
expect(attentionStatus(buildItem({ sourceKind: "issue_thread_interaction", detail: planApproval() }))).toBe(
"in_review",
);
});
it("keeps blocking and review distinct", () => {
expect(attentionStatus(buildItem({ sourceKind: "agent_error_alert" }))).not.toBe(
attentionStatus(buildItem({ sourceKind: "approval" })),
);
});
it("borrows exactly two task statuses — and never keys colour off severity", () => {
expect(attentionStatus(buildItem({ sourceKind: "agent_error_alert" }))).toBe("blocked");
expect(attentionStatus(buildItem({ sourceKind: "approval" }))).toBe("in_review");
// Same source, opposite severities → identical status (colour ≠ severity).
expect(attentionStatus(buildItem({ sourceKind: "failed_run", severity: "critical" }))).toBe(
attentionStatus(buildItem({ sourceKind: "failed_run", severity: "low" })),
);
});
});
// The feed stores the task in two different fields depending on what the row is
// about. Reading only `relatedIssue` silently dropped the key on every row whose
// subject *is* the task — reviews and blocked dependencies, i.e. the rows most
// obviously about a task.
describe("attentionTaskRef", () => {
it("reads the task off the subject when the subject IS the task", () => {
const item = buildItem({
sourceKind: "blocker_attention",
subject: {
kind: "issue",
id: "i1",
companyId: "c1",
title: "Update primary paperclip instance",
identifier: "PAP-23",
status: "blocked",
href: "/PAP/issues/PAP-23",
},
});
expect(attentionTaskRef(item)).toEqual({ identifier: "PAP-23", href: "/PAP/issues/PAP-23" });
});
it("reads the task off relatedIssue when the subject merely hangs off one", () => {
const item = buildItem({
sourceKind: "issue_thread_interaction",
subject: {
kind: "interaction",
id: "x1",
companyId: "c1",
title: "Ship it?",
identifier: null,
status: "pending",
href: "/PAP/issues/PAP-20#interaction-x1",
},
relatedIssue: {
kind: "issue",
id: "i2",
companyId: "c1",
title: "Produce launch video",
identifier: "PAP-20",
status: "in_review",
href: "/PAP/issues/PAP-20",
},
});
expect(attentionTaskRef(item)).toEqual({ identifier: "PAP-20", href: "/PAP/issues/PAP-20" });
});
it("prefers relatedIssue when both are present — it is the record the subject can't describe", () => {
const item = buildItem({
subject: {
kind: "issue",
id: "i1",
companyId: "c1",
title: "Subject task",
identifier: "PAP-1",
status: "todo",
href: "/PAP/issues/PAP-1",
},
relatedIssue: {
kind: "issue",
id: "i2",
companyId: "c1",
title: "Related task",
identifier: "PAP-2",
status: "todo",
href: "/PAP/issues/PAP-2",
},
});
expect(attentionTaskRef(item)?.identifier).toBe("PAP-2");
});
it("returns null for rows genuinely not attached to a task", () => {
// A hire approval: subject is the approval itself, no task anywhere.
expect(attentionTaskRef(buildItem({ sourceKind: "approval" }))).toBeNull();
// An agent error: subject is the agent.
expect(
attentionTaskRef(
buildItem({
sourceKind: "agent_error_alert",
subject: {
kind: "agent",
id: "ag1",
companyId: "c1",
title: "CTO",
identifier: null,
status: "error",
href: "/PAP/agents/ag1",
},
}),
),
).toBeNull();
});
it("does not borrow a key from a non-task subject that happens to have one", () => {
const item = buildItem({
subject: {
kind: "approval",
id: "ap1",
companyId: "c1",
title: "Sign off",
identifier: "APR-9",
status: "pending",
href: "/PAP/approvals/ap1",
},
});
expect(attentionTaskRef(item)).toBeNull();
});
});
describe("attentionDetailLine (§7)", () => {
it("summarizes questions with a count and the first question", () => {
const line = attentionDetailLine(
buildItem({
detail: { kind: "questions", questionCount: 2, firstQuestionText: "Which auth provider?", images: [] },
}),
);
expect(line).toContain("2 questions");
expect(line).toContain("Which auth provider?");
});
it("singularizes a single suggested task", () => {
const line = attentionDetailLine(
buildItem({
detail: { kind: "suggested_tasks", taskCount: 1, firstTaskTitle: "Add index", images: [] },
}),
);
expect(line).toContain("1 suggested task");
expect(line).not.toContain("tasks");
});
it("renders a failed run as agent — reason", () => {
const line = attentionDetailLine(
buildItem({
sourceKind: "failed_run",
detail: { kind: "failed_run", agentName: "Deployer", failureReasonExcerpt: "exit code 1", images: [] },
}),
);
expect(line).toContain("Deployer");
expect(line).toContain("exit code 1");
});
it("returns null when there is no detail block", () => {
expect(attentionDetailLine(buildItem({ detail: null }))).toBeNull();
});
});
describe("sortAttentionItems", () => {
const older = buildItem({ id: "old", activityAt: "2026-07-01T00:00:00Z", rank: 5 });
const newer = buildItem({ id: "new", activityAt: "2026-07-09T00:00:00Z", rank: 9 });
it("puts newest first by default", () => {
expect(sortAttentionItems([older, newer], "newest").map((i) => i.id)).toEqual(["new", "old"]);
});
it("reverses to oldest first", () => {
expect(sortAttentionItems([older, newer], "oldest").map((i) => i.id)).toEqual(["old", "new"]);
});
it("breaks activity ties by rank (lower rank wins) regardless of order", () => {
const a = buildItem({ id: "a", activityAt: "2026-07-09T00:00:00Z", rank: 2 });
const b = buildItem({ id: "b", activityAt: "2026-07-09T00:00:00Z", rank: 1 });
expect(sortAttentionItems([a, b], "newest").map((i) => i.id)).toEqual(["b", "a"]);
expect(sortAttentionItems([a, b], "oldest").map((i) => i.id)).toEqual(["b", "a"]);
});
it("does not mutate the input array", () => {
const input = [older, newer];
sortAttentionItems(input, "newest");
expect(input.map((i) => i.id)).toEqual(["old", "new"]);
});
});
// `attentionDateBucket` walks back from the start of the *local* day
// (`setHours(0, 0, 0, 0)`), so every date fixture below is local too. Pinned to
// UTC instants they drift across the boundary under test: `2026-07-09T23:00:00Z`
// is 08:00 on the 10th at UTC+9 and buckets as "today", and at UTC+14 and UTC-11
// even the mid-morning fixtures land on the wrong calendar day.
const localTime = (month: number, day: number, hour: number) =>
new Date(2026, month - 1, day, hour, 0, 0, 0);
describe("attentionDateBucket", () => {
const now = localTime(7, 10, 12).getTime();
it("buckets by rolling calendar-day windows relative to now", () => {
expect(attentionDateBucket(localTime(7, 10, 9).toISOString(), now)).toBe("today");
expect(attentionDateBucket(localTime(7, 9, 23).toISOString(), now)).toBe("yesterday");
expect(attentionDateBucket(localTime(7, 6, 9).toISOString(), now)).toBe("this_week");
expect(attentionDateBucket(localTime(6, 1, 9).toISOString(), now)).toBe("earlier");
});
it("treats invalid timestamps as earlier", () => {
expect(attentionDateBucket("not-a-date", now)).toBe("earlier");
});
});
describe("groupAttentionItems", () => {
const now = localTime(7, 10, 12).getTime();
it("leaves None as one unlabeled group that preserves caller sort order", () => {
const items = sortAttentionItems(
[
buildItem({ id: "old", activityAt: "2026-07-10T08:00:00Z" }),
buildItem({ id: "new", activityAt: "2026-07-10T10:00:00Z" }),
],
"newest",
);
const groups = groupAttentionItems(items, "none", { now });
expect(groups).toHaveLength(1);
expect(groups[0].label).toBeNull();
expect(groups[0].items.map((i) => i.id)).toEqual(["new", "old"]);
});
it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => {
const items = [
buildItem({ id: "earlier", activityAt: localTime(6, 1, 9).toISOString() }),
buildItem({ id: "today", activityAt: localTime(7, 10, 8).toISOString() }),
buildItem({ id: "yesterday", activityAt: localTime(7, 9, 8).toISOString() }),
];
const groups = groupAttentionItems(items, "date", { now });
expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday", "Earlier"]);
expect(groups.map((g) => g.key)).toEqual(["date:today", "date:yesterday", "date:earlier"]);
});
it("groups by severity in escalation order regardless of input order", () => {
const items = [
buildItem({ id: "low", severity: "low" }),
buildItem({ id: "crit", severity: "critical" }),
buildItem({ id: "med", severity: "medium" }),
];
const groups = groupAttentionItems(items, "severity");
expect(groups.map((g) => g.label)).toEqual(["Critical", "Medium", "Low"]);
});
it("groups by project, keeping a 'No project' bucket for unassigned rows", () => {
const items = [
buildItem({ id: "p1", activityAt: "2026-07-10T10:00:00Z", project: { id: "proj-1", name: "Alpha", urlKey: "alpha", color: null, icon: null } }),
buildItem({ id: "none", activityAt: "2026-07-10T11:00:00Z", project: null }),
];
const groups = groupAttentionItems(items, "project");
const noneGroup = groups.find((g) => g.key === `project:${NO_GROUP_SENTINEL}`);
expect(noneGroup?.label).toBe("No project");
expect(groups.find((g) => g.key === "project:proj-1")?.label).toBe("Alpha");
// Freshest group floats first (No project row is newer).
expect(groups[0].key).toBe(`project:${NO_GROUP_SENTINEL}`);
});
it("groups by type using source labels", () => {
const items = [
buildItem({ id: "a", sourceKind: "approval" }),
buildItem({ id: "j", sourceKind: "join_request" }),
];
const groups = groupAttentionItems(items, "type");
expect(groups.map((g) => g.key).sort()).toEqual(["type:approval", "type:join_request"]);
});
it("preserves the caller-provided intra-group order (sort governs within a bucket)", () => {
const items = sortAttentionItems(
[
buildItem({ id: "t1", activityAt: localTime(7, 10, 8).toISOString() }),
buildItem({ id: "t2", activityAt: localTime(7, 10, 10).toISOString() }),
],
"newest",
);
const [today] = groupAttentionItems(items, "date", { now });
expect(today.items.map((i) => i.id)).toEqual(["t2", "t1"]);
});
it("returns no groups for an empty list", () => {
expect(groupAttentionItems([], "date", { now })).toEqual([]);
});
});
describe("filterAttentionItems", () => {
const approval = buildItem({ id: "ap", sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a", color: null, icon: null } });
const join = buildItem({ id: "jn", sourceKind: "join_request", severity: "low", project: null });
const items = [approval, join];
it("returns everything when no filters are active", () => {
expect(filterAttentionItems(items, defaultAttentionFilterState)).toHaveLength(2);
expect(countActiveAttentionFilters(defaultAttentionFilterState)).toBe(0);
});
it("filters by source kind", () => {
const result = filterAttentionItems(items, { ...defaultAttentionFilterState, sourceKinds: ["approval"] });
expect(result.map((i) => i.id)).toEqual(["ap"]);
});
it("filters by severity", () => {
const result = filterAttentionItems(items, { ...defaultAttentionFilterState, severities: ["low"] });
expect(result.map((i) => i.id)).toEqual(["jn"]);
});
it("filters by project id and the no-project sentinel", () => {
expect(filterAttentionItems(items, { ...defaultAttentionFilterState, projectIds: ["p1"] }).map((i) => i.id)).toEqual(["ap"]);
expect(
filterAttentionItems(items, { ...defaultAttentionFilterState, projectIds: [NO_GROUP_SENTINEL] }).map((i) => i.id),
).toEqual(["jn"]);
});
it("ANDs across dimensions", () => {
const result = filterAttentionItems(items, {
...defaultAttentionFilterState,
sourceKinds: ["approval"],
severities: ["low"],
});
expect(result).toHaveLength(0);
});
});
describe("buildAttentionFilterOptions", () => {
it("collects the distinct dimensions present in the feed", () => {
const items = [
buildItem({ sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a", color: null, icon: null }, workspace: { id: "w1", name: "WS" } }),
buildItem({ sourceKind: "join_request", severity: "low", project: null, workspace: null }),
];
const options = buildAttentionFilterOptions(items);
expect(options.sourceKinds.sort()).toEqual(["approval", "join_request"]);
expect(options.severities).toEqual(["high", "low"]);
expect(options.projects.map((p) => p.id)).toEqual(["p1"]);
expect(options.workspaces.map((w) => w.id)).toEqual(["w1"]);
expect(options.hasNoProject).toBe(true);
expect(options.hasNoWorkspace).toBe(true);
});
});
describe("planAttentionRenderRows (PAP-13784 incremental rendering)", () => {
const items = (prefix: string, count: number) =>
Array.from({ length: count }, (_, i) => buildItem({ id: `${prefix}${i}` }));
it("allocates the budget across groups in document order", () => {
const plan = planAttentionRenderRows({
groups: [
{ key: "g1", label: "One", items: items("a", 3) },
{ key: "g2", label: "Two", items: items("b", 3) },
],
collapsedGroupKeys: new Set(),
snoozedItems: [],
snoozedOpen: false,
dismissedItems: [],
dismissedOpen: false,
limit: 4,
});
expect(plan.groupRows.get("g1")).toHaveLength(3);
expect(plan.groupRows.get("g2")).toHaveLength(1);
expect(plan.hasMoreRows).toBe(true);
});
it("renders everything and reports no more rows when the budget covers the feed", () => {
const plan = planAttentionRenderRows({
groups: [{ key: "g1", label: null, items: items("a", 5) }],
collapsedGroupKeys: new Set(),
snoozedItems: items("s", 2),
snoozedOpen: true,
dismissedItems: items("d", 2),
dismissedOpen: true,
limit: 9,
});
expect(plan.groupRows.get("g1")).toHaveLength(5);
expect(plan.snoozedRows).toHaveLength(2);
expect(plan.dismissedRows).toHaveLength(2);
expect(plan.hasMoreRows).toBe(false);
});
it("collapsed groups and closed curtains consume no budget and never truncate", () => {
const plan = planAttentionRenderRows({
groups: [
{ key: "g1", label: "One", items: items("a", 50) },
{ key: "g2", label: "Two", items: items("b", 2) },
],
collapsedGroupKeys: new Set(["g1"]),
snoozedItems: items("s", 50),
snoozedOpen: false,
dismissedItems: [],
dismissedOpen: false,
limit: 2,
});
expect(plan.groupRows.get("g1")).toHaveLength(0);
expect(plan.groupRows.get("g2")).toHaveLength(2);
expect(plan.snoozedRows).toHaveLength(0);
expect(plan.hasMoreRows).toBe(false);
});
it("curtains draw from the same budget after the active groups", () => {
const plan = planAttentionRenderRows({
groups: [{ key: "g1", label: null, items: items("a", 3) }],
collapsedGroupKeys: new Set(),
snoozedItems: items("s", 5),
snoozedOpen: true,
dismissedItems: items("d", 5),
dismissedOpen: true,
limit: 5,
});
expect(plan.groupRows.get("g1")).toHaveLength(3);
expect(plan.snoozedRows).toHaveLength(2);
expect(plan.dismissedRows).toHaveLength(0);
expect(plan.hasMoreRows).toBe(true);
});
});