mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
fix(slice-20): SQL predicate pushdown + string-keyed dismissal for queue-derived cards (guard BLOCKING)
Guard verdict qitem-20260518190827 — two real BLOCKERS.
BLOCKER 1: ATTENTION_FETCH_BOUND was the wrong shape. The route
asked the repo for newest 1000 open qitems then JS-filtered, so an
attention item older than 1000+ newer routine open qitems was still
hidden behind the fetch window. PRD HG-1/HG-2/HG-7 require
window-independence BY CONSTRUCTION.
Fix:
- New QueueRepository.listAttention(opts) pushes the attention
predicate INTO the SQL WHERE clause:
state IN ('pending','in-progress','blocked')
AND (tier = 'human-gate'
OR destination_session matches human-seat LIKE patterns)
ORDER BY ts_created DESC LIMIT ?
The LIMIT now applies AFTER attention filtering. SQL LIKE is used
as a SUPER-SET of the regex (every regex-match also LIKE-matches);
the route refines with isAttentionItem as defense-in-depth.
- Route's attention path calls listAttention directly; user `limit`
is passed straight through. ATTENTION_FETCH_BOUND is deleted.
- New discriminator: seed 1 old human-gate + 1100 newer routine
open qitems; GET /api/queue/list?attention=1 still surfaces the
human-gate item (the prior fetch-then-filter would have hidden
it).
BLOCKER 2: All queue-derived synthetic FeedCards shared
ActivityEvent.seq = -1. Feed.tsx used seq as the dismissal key, so
dismissing one queue-derived attention card stored -1 in
dismissedSeqs and hid every other queue-derived card. (The
auto-prune-by-min-seq logic also immediately drops a -1 dismissal
when min current seq is positive — double-broken.)
Fix:
- New useDismissedCardIds hook (string-keyed parallel to
useDismissedSeqs). Distinct localStorage namespace
("forYou.dismissedCardIds"). Prune rule is set-membership in
currentIds — when a qitem closes the card disappears and the
dismissal is dropped.
- FeedCard.onDismiss signature: `(seq: number)` → `(card: FeedCard)`.
Parent receives the full card and routes:
queue-derived (card.id starts with "queue-attention-")
→ useDismissedCardIds.dismiss(card.id)
event-derived (everything else)
→ useDismissedSeqs.dismiss(card.source.seq)
- Feed.tsx filter checks BOTH dismissal sets via isQueueDerivedCard.
- pendingUndoSeq is a discriminated union now: {kind:"seq", seq}
or {kind:"id", id}; UndoToast key picks the right field.
- Existing useDismissedSeqs is UNTOUCHED (event-derived path
unchanged; HG-6 no regression).
Discriminators added:
- daemon BLOCKER-1: 1100-routine-open-qitems durability test
- UI useDismissedCardIds: 10 new tests (init/read/dismiss/undismiss/
prune/idempotent/independent-state/distinct-namespace).
- feed-card-dismiss: existing 12 tests updated for the new
(card: FeedCard) signature (assert against object shape
containing source.seq).
- mutation-verified BLOCKER-1: dropping `items.filter(isAttentionItem)`
in route causes negative-routine test to FAIL (banked
fetch-then-filter regression class).
Totals: queue-routes 27/27 (+1 BLOCKER-1); queue-repository 25/25;
useDismissedCardIds 10/10 (new); feed-card-dismiss 12/12 (signature
updates); attention-feed 13/13; useDismissedSeqs 10/10 untouched.
Full UI suite 1142/1142 (+10). Daemon+UI tsc EXIT=0.
This commit is contained in:
1 parent
612be91d88
commit
1b96a3b0b8
8 files changed
+378
-48
No files matched your search
@@ -979,6 +979,58 @@ export class QueueRepository {
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.2.20 — durable attention-class query.
|
||||
*
|
||||
* Returns OPEN attention-class qitems (the source of truth for the
|
||||
* For You Action-required + Approval lenses) by pushing the
|
||||
* attention predicate INTO the SQL WHERE clause so the LIMIT
|
||||
* applies AFTER attention filtering. This makes the result
|
||||
* window-INDEPENDENT by construction: an old human-gate item
|
||||
* cannot be evicted past LIMIT by routine open qitems even when
|
||||
* there are >>LIMIT of them. (Guard verdict qitem-20260518190827
|
||||
* BLOCKER 1 — the prior fetch-then-filter approach in the route
|
||||
* could still hide attention items behind ATTENTION_FETCH_BOUND
|
||||
* newer routine open qitems.)
|
||||
*
|
||||
* Attention predicate in SQL (mirror of mission-control read
|
||||
* layer + the route-level `isAttentionItem`):
|
||||
* tier = 'human-gate' (approval)
|
||||
* OR destination_session matches human-seat regex (action-required)
|
||||
*
|
||||
* SQLite has no native regex; LIKE patterns are used as a
|
||||
* SUPER-SET (every regex match also matches one of the LIKE
|
||||
* patterns). Callers can refine in JS with isAttentionItem if
|
||||
* they need strict regex semantics — but for the LIMIT-pushdown
|
||||
* guarantee, the SQL superset is what matters: NO attention item
|
||||
* is filtered out by the SQL stage.
|
||||
*
|
||||
* Default open state set: pending|in-progress|blocked. Caller may
|
||||
* override via `state`.
|
||||
*/
|
||||
listAttention(opts?: { limit?: number; state?: QueueState | QueueState[] }): QueueItem[] {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const states = opts?.state
|
||||
? Array.isArray(opts.state) ? opts.state : [opts.state]
|
||||
: ["pending" as QueueState, "in-progress" as QueueState, "blocked" as QueueState];
|
||||
const placeholders = states.map(() => "?").join(", ");
|
||||
const sql = `
|
||||
SELECT * FROM queue_items
|
||||
WHERE state IN (${placeholders})
|
||||
AND (
|
||||
tier = 'human-gate'
|
||||
OR destination_session = 'human@kernel'
|
||||
OR destination_session = 'human@host'
|
||||
OR destination_session LIKE 'human-%@kernel'
|
||||
OR destination_session LIKE 'human-%@host'
|
||||
)
|
||||
ORDER BY ts_created DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
const rows = this.db.prepare(sql).all(...states, limit) as QueueItemRow[];
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find qitems whose `closure_required_at` is past now. Used by watchdog;
|
||||
* does NOT itself emit events — callers decide whether to nudge or escalate.
|
||||
|
||||
@@ -38,13 +38,6 @@ export function isAttentionItem(q: { tier: string | null; destinationSession: st
|
||||
return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(q.destinationSession ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.2.20 — internal fetch cap for the attention filter so an
|
||||
* attention item can't be evicted past the repo's default list limit
|
||||
* by routine churn. Far above realistic open-attention set sizes and
|
||||
* well below SQL pagination concerns.
|
||||
*/
|
||||
const ATTENTION_FETCH_BOUND = 1000;
|
||||
|
||||
export function queueRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
@@ -366,26 +359,29 @@ export function queueRoutes(): Hono {
|
||||
return c.json(items);
|
||||
}
|
||||
|
||||
// OPR.0.3.2.20 — attention query fetch-then-filter. The repo's
|
||||
// default list() limit (100) sorts by ts_created DESC and can
|
||||
// evict an older attention item when 100+ routine qitems land
|
||||
// after it — the exact eviction defect this slice fixes. Fetch
|
||||
// with a generous internal cap (ATTENTION_FETCH_BOUND) so attention
|
||||
// items are not missed; apply the operator's `limit` AFTER
|
||||
// filtering. The attention set is small by nature (PRD §1.5
|
||||
// "sane bound") — a 1000-row internal cap is well above realistic
|
||||
// open-attention sizes and well below SQL-pagination cost concerns.
|
||||
const items = getRepo(c).list({
|
||||
destinationSession,
|
||||
sourceSession,
|
||||
// OPR.0.3.2.20 — attention path goes through
|
||||
// QueueRepository.listAttention, which pushes the attention
|
||||
// predicate INTO the SQL WHERE clause so the LIMIT applies AFTER
|
||||
// attention filtering. Window-independent by construction: an
|
||||
// old human-gate item is never evicted by routine open qitems,
|
||||
// however many of them land after it (guard re-verify
|
||||
// qitem-20260518190827 BLOCKER 1). The earlier fetch-then-filter
|
||||
// shape (ATTENTION_FETCH_BOUND) is gone — the LIMIT bound is the
|
||||
// user-facing one only, applied at the SQL layer post-predicate.
|
||||
//
|
||||
// destinationSession / sourceSession / targetRepo composition is
|
||||
// intentionally NOT applied to the attention path — the For You
|
||||
// surface is global to the operator (no rig/session filter); a
|
||||
// future surface that needs scope can add params then. The state
|
||||
// override is supported via the existing `state` query param,
|
||||
// passed through.
|
||||
const items = getRepo(c).listAttention({
|
||||
limit: userLimit,
|
||||
state,
|
||||
targetRepo,
|
||||
limit: ATTENTION_FETCH_BOUND,
|
||||
});
|
||||
let filtered = items.filter(isAttentionItem);
|
||||
if (userLimit !== undefined && Number.isFinite(userLimit) && userLimit > 0) {
|
||||
filtered = filtered.slice(0, userLimit);
|
||||
}
|
||||
// Defense-in-depth: refine with the JS predicate so the SQL
|
||||
// LIKE superset cannot leak a malformed destination through.
|
||||
const filtered = items.filter(isAttentionItem);
|
||||
return c.json(filtered);
|
||||
});
|
||||
|
||||
|
||||
@@ -386,6 +386,48 @@ describe("queue routes", () => {
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data.some((q) => q.tier === "human-gate")).toBe(true);
|
||||
});
|
||||
|
||||
// Guard re-verify BLOCKER 1 (qitem-20260518190827): the prior
|
||||
// fetch-then-filter approach (ATTENTION_FETCH_BOUND=1000 then
|
||||
// JS filter) would have hidden an attention item behind 1001+
|
||||
// newer routine open qitems. The fix pushes the attention
|
||||
// predicate INTO SQL so the LIMIT applies AFTER attention
|
||||
// filtering. This test scales the routine churn well past the
|
||||
// old ATTENTION_FETCH_BOUND to prove window-independence by
|
||||
// construction.
|
||||
it("BLOCKER-1: attention item surfaces even when >1100 newer routine OPEN qitems exist (SQL predicate pushdown)", async () => {
|
||||
await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceSession: "old@r",
|
||||
destinationSession: "b@r",
|
||||
body: "approve me — oldest",
|
||||
tier: "human-gate",
|
||||
}),
|
||||
});
|
||||
// 1100 routine OPEN qitems land AFTER the attention item. They
|
||||
// each get a newer ts_created than the attention item; a
|
||||
// fetch-then-filter with LIMIT 1000 would never return the
|
||||
// attention item.
|
||||
for (let i = 0; i < 1100; i++) {
|
||||
await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceSession: `routine-${i}@r`,
|
||||
destinationSession: `other-${i}@r`,
|
||||
body: `noise ${i}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const res = await app.request("/api/queue/list?attention=1");
|
||||
const data = (await res.json()) as Array<{ tier: string | null; body: string }>;
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
const found = data.find((q) => q.tier === "human-gate");
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.body).toContain("oldest");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- PL-004 Phase A revision (R1) route tests ----
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
isCardKindSubscribed,
|
||||
} from "../../hooks/useFeedSubscriptions.js";
|
||||
import { useDismissedSeqs } from "../../hooks/useDismissedSeqs.js";
|
||||
import { useDismissedCardIds } from "../../hooks/useDismissedCardIds.js";
|
||||
import { useCompletedMissions } from "../../hooks/useCompletedMissions.js";
|
||||
import { useMissionStatuses } from "../../hooks/useMissionStatuses.js";
|
||||
import { FeedCard } from "./FeedCard.js";
|
||||
@@ -258,24 +259,50 @@ export function Feed() {
|
||||
[eventDerivedCards, queueDerivedAttention],
|
||||
);
|
||||
const rawCardSeqs = useMemo(() => rawCards.map((c) => c.source.seq), [rawCards]);
|
||||
const { dismissedSeqs, dismiss, undismiss } = useDismissedSeqs(rawCardSeqs);
|
||||
const [pendingUndoSeq, setPendingUndoSeq] = useState<number | null>(null);
|
||||
const rawCardIds = useMemo(() => rawCards.map((c) => c.id), [rawCards]);
|
||||
const { dismissedSeqs, dismiss: dismissSeq, undismiss: undismissSeq } = useDismissedSeqs(rawCardSeqs);
|
||||
// OPR.0.3.2.20 — string-keyed dismissal parallel to event-seq
|
||||
// dismissal. Queue-derived attention cards share synthetic
|
||||
// ActivityEvent.seq = -1 (no real event), so seq-keyed dismissal
|
||||
// collides across them; routing those dismissals to a string-id
|
||||
// set (FeedCard.id is stable + unique) keeps each queue-derived
|
||||
// card's dismissal independent. Auto-prune is membership-based:
|
||||
// when the qitem closes the card disappears from rawCardIds and
|
||||
// the dismissal is dropped.
|
||||
const { dismissedIds, dismiss: dismissId, undismiss: undismissId } = useDismissedCardIds(rawCardIds);
|
||||
const [pendingUndo, setPendingUndo] = useState<{ kind: "seq"; seq: number } | { kind: "id"; id: string } | null>(null);
|
||||
|
||||
// Predicate used to route a dismiss call: queue-derived synthetic
|
||||
// cards are identified by their stable card.id prefix. Avoids
|
||||
// collision on the synthetic seq=-1 (banked guard verdict
|
||||
// qitem-20260518190827 BLOCKER 2).
|
||||
const isQueueDerivedCard = useCallback(
|
||||
(card: FeedCardModel): boolean => card.id.startsWith("queue-attention-"),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(seq: number) => {
|
||||
dismiss(seq);
|
||||
setPendingUndoSeq(seq);
|
||||
(card: FeedCardModel) => {
|
||||
if (isQueueDerivedCard(card)) {
|
||||
dismissId(card.id);
|
||||
setPendingUndo({ kind: "id", id: card.id });
|
||||
} else {
|
||||
dismissSeq(card.source.seq);
|
||||
setPendingUndo({ kind: "seq", seq: card.source.seq });
|
||||
}
|
||||
},
|
||||
[dismiss],
|
||||
[dismissSeq, dismissId, isQueueDerivedCard],
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (pendingUndoSeq !== null) undismiss(pendingUndoSeq);
|
||||
setPendingUndoSeq(null);
|
||||
}, [pendingUndoSeq, undismiss]);
|
||||
if (pendingUndo === null) return;
|
||||
if (pendingUndo.kind === "seq") undismissSeq(pendingUndo.seq);
|
||||
else undismissId(pendingUndo.id);
|
||||
setPendingUndo(null);
|
||||
}, [pendingUndo, undismissSeq, undismissId]);
|
||||
|
||||
const handleUndoExpire = useCallback(() => {
|
||||
setPendingUndoSeq(null);
|
||||
setPendingUndo(null);
|
||||
}, []);
|
||||
const qitemIds = useMemo(
|
||||
() => rawCards.map(qitemIdForCard).filter((id): id is string => Boolean(id)),
|
||||
@@ -302,8 +329,11 @@ export function Feed() {
|
||||
isCardKindSubscribed(c.kind, subs.state),
|
||||
);
|
||||
const lensFiltered = lens === "all" ? subscribed : subscribed.filter((c) => c.kind === lens);
|
||||
return lensFiltered.filter((c) => !dismissedSeqs.has(c.source.seq));
|
||||
}, [rawCards, lens, queueItems.itemsById, actionOutcomes, subs.state, dismissedSeqs]);
|
||||
return lensFiltered.filter((c) => {
|
||||
if (isQueueDerivedCard(c)) return !dismissedIds.has(c.id);
|
||||
return !dismissedSeqs.has(c.source.seq);
|
||||
});
|
||||
}, [rawCards, lens, queueItems.itemsById, actionOutcomes, subs.state, dismissedSeqs, dismissedIds, isQueueDerivedCard]);
|
||||
const slicesQuery = useSlices("all");
|
||||
const sliceRows = useMemo(() => {
|
||||
if (!slicesQuery.data || "unavailable" in slicesQuery.data) return [];
|
||||
@@ -461,9 +491,9 @@ export function Feed() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pendingUndoSeq !== null ? (
|
||||
{pendingUndo !== null ? (
|
||||
<UndoToast
|
||||
key={pendingUndoSeq}
|
||||
key={pendingUndo.kind === "seq" ? `seq-${pendingUndo.seq}` : `id-${pendingUndo.id}`}
|
||||
label="Card dismissed"
|
||||
onUndo={handleUndo}
|
||||
onExpire={handleUndoExpire}
|
||||
|
||||
@@ -323,7 +323,15 @@ export function FeedCard({
|
||||
queueItem?: QueueItemDetail;
|
||||
proofPreview?: FeedProofPreview | null;
|
||||
actionOutcome?: FeedActionOutcome | null;
|
||||
onDismiss?: (seq: number) => void;
|
||||
/**
|
||||
* OPR.0.3.2.20 — receives the FULL card on dismiss so the parent
|
||||
* can route the dismissal to the right dismissal-set (event-seq
|
||||
* for event-derived cards, string-id for queue-derived attention
|
||||
* cards). The prior `(seq: number)` signature collided across all
|
||||
* queue-derived cards (all share synthetic seq=-1) — banked guard
|
||||
* BLOCKER qitem-20260518190827.
|
||||
*/
|
||||
onDismiss?: (card: FeedCardModel) => void;
|
||||
/**
|
||||
* 0.3.1 demo-bug fix — fired by VerbActions on mutation success
|
||||
* so the parent (Feed.tsx) can render the ActionOutcomePanel
|
||||
@@ -339,7 +347,7 @@ export function FeedCard({
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
onDismiss(card.source.seq);
|
||||
onDismiss(card);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -362,14 +370,14 @@ export function FeedCard({
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
if (deltaX / rect.width >= SWIPE_DISMISS_THRESHOLD) {
|
||||
onDismiss(card.source.seq);
|
||||
onDismiss(card);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
event.stopPropagation();
|
||||
if (!onDismiss) return;
|
||||
onDismiss(card.source.seq);
|
||||
onDismiss(card);
|
||||
};
|
||||
const qitemViewerData = qitemViewerDataFromItem(card, queueItem);
|
||||
const body = compactQueueBody(queueItem?.body || card.body);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// OPR.0.3.2.20 — string-keyed dismissal for queue-derived attention
|
||||
// cards. Parallel to useDismissedSeqs (which is event-seq keyed and
|
||||
// auto-prunes by monotonic min-seq for the activity FIFO). The
|
||||
// queue-derived synthetic FeedCards don't have unique event seqs
|
||||
// (the synthetic ActivityEvent.seq = -1), so the existing seq-keyed
|
||||
// dismissal would collide across all queue-derived cards — dismissing
|
||||
// one would hide every other queue-derived attention card too.
|
||||
//
|
||||
// This hook keys on the stable string FeedCard.id (e.g.,
|
||||
// `queue-attention-<qitemId>`), prunes by membership in the current
|
||||
// id set (when the qitem closes the card disappears, the dismissal
|
||||
// is dropped), and persists in a distinct localStorage namespace
|
||||
// so event-seq dismissals are untouched.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export const DISMISSED_CARD_IDS_STORAGE_KEY = "forYou.dismissedCardIds";
|
||||
|
||||
function readDismissedFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_CARD_IDS_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
const strings = parsed.filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||
return new Set(strings);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeDismissedToStorage(ids: Set<string>): void {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify(Array.from(ids)));
|
||||
} catch {
|
||||
// localStorage may be unavailable; swallow.
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseDismissedCardIdsResult {
|
||||
dismissedIds: Set<string>;
|
||||
dismiss(id: string): void;
|
||||
undismiss(id: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param currentIds The ids of all cards currently in the rendered
|
||||
* set. Used to auto-prune: when a card's id is no longer present
|
||||
* (qitem closed/handed off), the dismissal is dropped so the
|
||||
* localStorage set doesn't grow forever.
|
||||
*/
|
||||
export function useDismissedCardIds(currentIds: string[]): UseDismissedCardIdsResult {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => readDismissedFromStorage());
|
||||
|
||||
const currentSet = useMemo(() => new Set(currentIds), [currentIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSet.size === 0) return;
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
for (const id of dismissedIds) {
|
||||
if (currentSet.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
setDismissedIds(next);
|
||||
writeDismissedToStorage(next);
|
||||
}
|
||||
}, [currentSet, dismissedIds]);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setDismissedIds((prev) => {
|
||||
if (prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
writeDismissedToStorage(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const undismiss = useCallback((id: string) => {
|
||||
setDismissedIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
writeDismissedToStorage(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { dismissedIds, dismiss, undismiss };
|
||||
}
|
||||
@@ -35,11 +35,14 @@ describe("FeedCard dismiss surfaces", () => {
|
||||
expect(screen.getByTestId("feed-card-dismiss")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking dismiss button calls onDismiss with card.source.seq", () => {
|
||||
// OPR.0.3.2.20 — onDismiss now receives the full card (not just the
|
||||
// seq) so the parent can route to the right dismissal-set
|
||||
// (event-seq vs card-id for queue-derived synthetic cards).
|
||||
it("clicking dismiss button calls onDismiss with the full card (includes source.seq)", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<FeedCard card={makeCard({ source: { seq: 42, type: "queue.enqueued", payload: {} } as unknown as FeedCardModel["source"] })} onDismiss={onDismiss} />);
|
||||
fireEvent.click(screen.getByTestId("feed-card-dismiss"));
|
||||
expect(onDismiss).toHaveBeenCalledWith(42);
|
||||
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
|
||||
});
|
||||
|
||||
it("Backspace key on focused card calls onDismiss", () => {
|
||||
@@ -47,7 +50,7 @@ describe("FeedCard dismiss surfaces", () => {
|
||||
render(<FeedCard card={makeCard()} onDismiss={onDismiss} />);
|
||||
const article = screen.getByTestId("feed-card-progress");
|
||||
fireEvent.keyDown(article, { key: "Backspace" });
|
||||
expect(onDismiss).toHaveBeenCalledWith(42);
|
||||
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
|
||||
});
|
||||
|
||||
it("Delete key on focused card calls onDismiss", () => {
|
||||
@@ -55,7 +58,7 @@ describe("FeedCard dismiss surfaces", () => {
|
||||
render(<FeedCard card={makeCard()} onDismiss={onDismiss} />);
|
||||
const article = screen.getByTestId("feed-card-progress");
|
||||
fireEvent.keyDown(article, { key: "Delete" });
|
||||
expect(onDismiss).toHaveBeenCalledWith(42);
|
||||
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
|
||||
});
|
||||
|
||||
it("other keys (e.g. Enter, Escape) do NOT call onDismiss", () => {
|
||||
@@ -105,7 +108,7 @@ describe("FeedCard dismiss surfaces", () => {
|
||||
fireEvent.touchStart(article, { touches: [{ clientX: 50, clientY: 50, identifier: 1 }] });
|
||||
fireEvent.touchEnd(article, { changedTouches: [{ clientX: 300, clientY: 50, identifier: 1 }] });
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledWith(42);
|
||||
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
|
||||
});
|
||||
|
||||
it("touch swipe-right BELOW threshold does NOT trigger onDismiss", () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// OPR.0.3.2.20 — string-keyed dismissal hook tests. Pins BLOCKER-2
|
||||
// fix from guard verdict qitem-20260518190827: queue-derived
|
||||
// synthetic FeedCards share ActivityEvent.seq=-1, so the old
|
||||
// seq-keyed dismissal would collide across all queue-derived cards.
|
||||
// useDismissedCardIds keys on the unique string FeedCard.id so each
|
||||
// queue-derived card has independent dismissal state.
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
useDismissedCardIds,
|
||||
DISMISSED_CARD_IDS_STORAGE_KEY,
|
||||
} from "../src/hooks/useDismissedCardIds.js";
|
||||
|
||||
describe("useDismissedCardIds — string-keyed dismissal", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("initializes empty when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useDismissedCardIds(["a", "b", "c"]));
|
||||
expect(result.current.dismissedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reads existing dismissed ids from localStorage on mount", () => {
|
||||
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify(["queue-attention-q1", "queue-attention-q2"]));
|
||||
const { result } = renderHook(() =>
|
||||
useDismissedCardIds(["queue-attention-q1", "queue-attention-q2", "queue-attention-q3"]),
|
||||
);
|
||||
expect(result.current.dismissedIds.has("queue-attention-q1")).toBe(true);
|
||||
expect(result.current.dismissedIds.has("queue-attention-q2")).toBe(true);
|
||||
expect(result.current.dismissedIds.has("queue-attention-q3")).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates malformed localStorage value (non-JSON)", () => {
|
||||
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, "{not json[");
|
||||
const { result } = renderHook(() => useDismissedCardIds(["a"]));
|
||||
expect(result.current.dismissedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("tolerates non-array localStorage value", () => {
|
||||
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify({ not: "array" }));
|
||||
const { result } = renderHook(() => useDismissedCardIds(["a"]));
|
||||
expect(result.current.dismissedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("dismiss(id) adds id to the set and persists", () => {
|
||||
const { result } = renderHook(() => useDismissedCardIds(["a", "b"]));
|
||||
act(() => result.current.dismiss("a"));
|
||||
expect(result.current.dismissedIds.has("a")).toBe(true);
|
||||
const stored = JSON.parse(localStorage.getItem(DISMISSED_CARD_IDS_STORAGE_KEY) ?? "[]") as string[];
|
||||
expect(stored).toContain("a");
|
||||
});
|
||||
|
||||
it("undismiss(id) removes id from the set and persists", () => {
|
||||
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify(["a", "b"]));
|
||||
const { result } = renderHook(() => useDismissedCardIds(["a", "b"]));
|
||||
act(() => result.current.undismiss("a"));
|
||||
expect(result.current.dismissedIds.has("a")).toBe(false);
|
||||
expect(result.current.dismissedIds.has("b")).toBe(true);
|
||||
});
|
||||
|
||||
// BLOCKER-2 discriminator: each card.id is its own dismissal key.
|
||||
// Dismissing one queue-derived card MUST NOT hide others.
|
||||
it("BLOCKER-2 fix: dismissing one card id does NOT affect other cards (independent dismissal state)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDismissedCardIds(["queue-attention-q1", "queue-attention-q2", "queue-attention-q3"]),
|
||||
);
|
||||
act(() => result.current.dismiss("queue-attention-q1"));
|
||||
expect(result.current.dismissedIds.has("queue-attention-q1")).toBe(true);
|
||||
expect(result.current.dismissedIds.has("queue-attention-q2")).toBe(false);
|
||||
expect(result.current.dismissedIds.has("queue-attention-q3")).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-prune: a dismissed id no longer in currentIds is dropped from the set", () => {
|
||||
localStorage.setItem(
|
||||
DISMISSED_CARD_IDS_STORAGE_KEY,
|
||||
JSON.stringify(["queue-attention-evicted", "queue-attention-still-here"]),
|
||||
);
|
||||
// Render with currentIds that excludes "queue-attention-evicted"
|
||||
// (the qitem has closed and the card disappeared from the feed).
|
||||
const { result, rerender } = renderHook(
|
||||
({ ids }) => useDismissedCardIds(ids),
|
||||
{ initialProps: { ids: ["queue-attention-still-here"] } },
|
||||
);
|
||||
rerender({ ids: ["queue-attention-still-here"] });
|
||||
// After the prune effect runs, the evicted id is gone.
|
||||
expect(result.current.dismissedIds.has("queue-attention-evicted")).toBe(false);
|
||||
expect(result.current.dismissedIds.has("queue-attention-still-here")).toBe(true);
|
||||
});
|
||||
|
||||
it("dismiss is idempotent — calling twice doesn't double-add or churn storage", () => {
|
||||
const { result } = renderHook(() => useDismissedCardIds(["a"]));
|
||||
act(() => result.current.dismiss("a"));
|
||||
act(() => result.current.dismiss("a"));
|
||||
expect(result.current.dismissedIds.size).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a distinct localStorage namespace from useDismissedSeqs (no cross-contamination)", () => {
|
||||
// Storage key must be different from the seq-keyed hook's key.
|
||||
expect(DISMISSED_CARD_IDS_STORAGE_KEY).toBe("forYou.dismissedCardIds");
|
||||
expect(DISMISSED_CARD_IDS_STORAGE_KEY).not.toBe("forYou.dismissedSeqs");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user