Files
PaperClipAI/ui/src/lib/attention.test.ts
T
DottaandPaperclip 3bafac12f7 refactor: remove automatic productivity reviews (#13263)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its recovery loop keeps assigned work moving after execution
failures.
> - Productivity review used run counts, comment counts, and elapsed
time to create management tasks.
> - Infrastructure failures could satisfy those rules and create more
tasks without evidence that the source work needed management review.
> - This pull request removes that detector and its continuation holds.
> - Bounded recovery, budgets, explicit blockers, and normal review
stages remain in place.
> - Existing task records stay readable and unchanged.

## Linked Issues or Issue Description

Refs #5897. That request describes unwanted automatic productivity
reviews and asks to preserve existing tasks. This change retires the
feature instead of adding another configuration switch.

Related prior approaches: Refs #9191, Refs #12489. Those changes
excluded infrastructure failures or bounded review creation. This
removal replaces the detector rather than tuning its thresholds.

## What Changed

- Delete the scheduled detector, automatic task creation, evidence
refresh, and productivity continuation holds.
- Remove computed productivity fields, special attention items, badges,
and Storybook fixtures.
- Retain historical origin values, decision compatibility, and recovery
recursion exclusions. Add no migration and change no existing task data.
- Update the execution contract. Replace feature tests with regressions
for legacy task reads, ordinary attention, and bounded continuation in
the presence of an old review.

## Verification

- Targeted attention, issue-route, startup, and UI tests: 4 files and
101 tests passed.
- Updated issue-route and UI tests: 2 files and 61 tests passed.
- Bounded continuation regression: 2 cases passed, including a legacy
review plus pre-dispatch cancellation churn.
- `pnpm check:token-gates`: all four gates passed.
- `git diff --check`: passed.
- `pnpm build-storybook`: passed.
- Greptile: 5/5 on `a5a612eea`, with no actionable findings.
- Scheduler and historical recovery regressions: 2 files and 28 tests
passed.
- Repository `pnpm -r typecheck` and `pnpm build`: passed.
- The complete `pnpm test:run` suite passed across the CI server,
serialized-server, and workspace shards on `a5a612eea`. Stopped the
duplicate local monolithic run after the full CI suite passed; no
completed local full-suite result is claimed. The targeted local suites
above passed.
- CI serialized shard 5 initially hit a 10-second timeout in the first
interaction-route test. The complete file passed locally (78 tests),
then the single CI rerun passed.
- All CI gates are green, including the build and end-to-end suites.
- A local merge check against current `master` (`ce09ea40b`) completed
without conflicts.

## Risks

- API responses no longer include the computed `productivityReview`
field. Consumers must stop using it.
- The scheduler no longer creates management work from elapsed time, run
counts, or missing comments. This is the intended behavior change.
- Existing review tasks and explicit dependencies remain in place.
Historical origins still prevent recursive recovery treatment. No task
cleanup or data migration occurs.
- The native review handoff repair is separate from this removal.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution. The exact runtime model identifier and context-window size
are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 15:46:35 -05:00

662 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("uses a generic task label for persisted legacy productivity decisions", () => {
expect(sourceMeta("productivity_review").label).toBe("Task");
});
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);
});
});