fix(web): resolve P2 + P3 review feedback on Phase 8 (PR #1314)

Addresses all 4 P2 + 3 P3 items from codex, Siri-Ray, and lefarcen.

State-lifecycle fixes (3 x P2)
1. Reducer learns a synthetic `__reset__` action (`CritiqueResetAction`).
   Host hooks dispatch it when their gating prop changes so a stale
   run from a prior project / transcript cannot bleed into the next
   context. Reset is idempotent on idle (returns the same reference).
2. `useCritiqueStream` dispatches `__reset__` at the top of its
   connection effect, so a workspace switch from project A (which
   streamed a critique) to project B clears the reducer before the
   new EventSource opens. enabled=false also clears.
3. `useCritiqueReplay` dispatches `__reset__` at the top of its
   parse effect, so transcriptUrl swaps (including swap-to-null after
   a replay reached `shipped`) lift the reducer back to idle before
   the new fetch starts.

SSE validation (1 x P2)
4. `sseToPanelEvent` now runs a per-variant `hasValidVariantShape`
   check after the cheap `isPanelEvent` predicate. A
   `critique.ship` frame missing `composite` / `round` / `status` /
   `artifactRef` is rejected before reaching the reducer, so
   TheaterCollapsed can no longer crash on `undefined.toFixed(1)`.
   Every variant's required fields are validated: run_started
   (protocolVersion, non-empty cast, maxRounds, threshold, scale),
   panelist_* (round, role, plus variant-specific shape), round_end
   (round, composite, mustFix, decision in {continue,ship}, reason),
   ship (round, composite, status, artifactRef.{projectId,artifactId},
   summary), degraded (reason, adapter), interrupted (bestRound,
   composite), failed (cause), parser_warning (kind, position).

Reducer correctness (1 x P2)
5. `panelist_open` now materializes the round + an empty panelist
   view (`{dims: [], mustFixes: []}`) so TheaterStage can highlight
   the in-progress lane the instant the tag opens. Before this, a
   stream that emitted only `panelist_open` after `run_started` left
   `rounds = []` and the UI rendered no current round until a later
   `panelist_dim` arrived.

Polish (3 x P3)
6. Brand role tint swaps from `var(--magenta, var(--accent))` to
   `var(--purple, var(--accent))`. `--purple` is actually defined
   across the design systems; `--magenta` is not, so Brand was
   silently falling through to `--accent` and looking identical to
   Designer.
7. New i18n key `critiqueTheater.interruptedSummary` for the
   interrupted-collapse copy ("Interrupted at round N, best
   composite X.X"). Previously the interrupted branch reused
   `shippedSummary` and the UI read "Shipped at round..." for a run
   that specifically did not ship. Native value in en + zh-CN; other
   locales fall back via `...en` spread.
8. `TheaterDegraded` heading id comes from `useId()` instead of a
   hardcoded `theater-degraded-heading`, so two chips rendered on
   the same page (chat history with multiple completed runs) keep
   their aria-labelledby references unambiguous.

Tests (15 new cases)
- reducer.test.ts (+5): __reset__ on running/terminal/idle, panelist_open materializes round, panelist_open does not stomp prior panelist data.
- sse.test.ts (+6): variant-level rejection for ship without required fields, degraded without adapter, run_started with empty cast, panelist_dim with non-numeric score, round_end with unknown decision, plus a positive fully-formed ship.
- useCritiqueStream.test.tsx (+2): state reset on projectId change, state reset on enabled flip false.
- useCritiqueReplay.test.tsx (+1): state reset on transcriptUrl swap to null after a replay reached shipped.
- TheaterCollapsed.test.tsx (text-pinning update): asserts the interrupted branch reads "Interrupted at round 1" + "best composite 7.9", and explicitly NOT "Shipped at round...".
- TheaterDegraded.test.tsx (+1): two chips on the same page get unique aria-labelledby ids that each resolve to an `<h3>`.

Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 13 files, 101 tests (was 86 on the first Phase 8 push, +15 new)
- tests/i18n/locales.test.ts 5 of 5 across 18 locales
This commit is contained in:
Nagendhra
2026-05-11 17:50:19 -04:00
parent a87e28c71c
commit 966f9c31e3
16 changed files with 383 additions and 8 deletions

View File

@@ -49,7 +49,7 @@ export function TheaterCollapsed({ state }: Props) {
{t('critiqueTheater.interrupted')}
</span>
<span className="theater-collapsed-summary">
{t('critiqueTheater.shippedSummary', {
{t('critiqueTheater.interruptedSummary', {
round: state.bestRound,
composite: state.composite.toFixed(1),
})}

View File

@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useT } from '../../i18n';
import type { Dict } from '../../i18n/types';
import type { DegradedReason } from '@open-design/contracts/critique';
@@ -24,14 +25,18 @@ const REASON_KEY: Record<DegradedReason, keyof Dict> = {
*/
export function TheaterDegraded({ reason, adapter }: Props) {
const t = useT();
// Per-instance heading id so two chips on the same page (e.g. a
// chat history that renders multiple completed runs) keep their
// aria-labelledby references unambiguous. Lefarcen P3 on PR #1314.
const headingId = useId();
return (
<section
className="theater-degraded"
role="status"
data-reason={reason}
aria-labelledby="theater-degraded-heading"
aria-labelledby={headingId}
>
<h3 id="theater-degraded-heading" className="theater-degraded-heading">
<h3 id={headingId} className="theater-degraded-heading">
{t('critiqueTheater.degradedHeading')}
</h3>
<p className="theater-degraded-reason">

View File

@@ -91,6 +91,14 @@ export function useCritiqueReplay(
// Stores the parsed events in component state so the pace effect can
// react to them; cursor is reset because a new transcript is a new run.
useEffect(() => {
// Whenever the URL changes (including swap to null), lift the
// reducer back to idle so a prior replay's terminal state cannot
// bleed into the new transcript or the "no transcript" empty
// state. Lefarcen + Siri-Ray + codex P2 on PR #1314: without this
// reset, a finished replay → null URL would leave the previous
// run's rounds visible while TheaterTranscript reported
// status: 'idle'.
dispatchRef.current({ type: '__reset__' });
if (!transcriptUrl) {
setMeta({ status: 'idle', error: null });
setEvents(null);

View File

@@ -55,6 +55,17 @@ export function useCritiqueStream(
const factory = options.connectionFactory ?? createCritiqueEventsConnection;
useEffect(() => {
// Clear any state from the previous project / enabled session
// before we decide whether to open a new connection. Without this,
// a workspace that switches from project A (which already streamed
// a critique) to project B would keep rendering project A's
// Theater state until B's run_started arrived, and a flip from
// enabled=true to enabled=false would leave the in-flight run
// visible after the connection had been torn down (lefarcen +
// Siri-Ray + codex P2 on PR #1314). The reducer's reset handler
// is a no-op when state is already idle, so this is cheap on
// the common path.
dispatchRef.current({ type: '__reset__' });
if (!enabled || !projectId) return;
if (typeof window === 'undefined' && !options.EventSourceCtor) return;
const conn = factory(

View File

@@ -8,7 +8,20 @@ import type {
ShipStatus,
} from '@open-design/contracts/critique';
export type CritiqueAction = PanelEvent;
/**
* Synthetic reducer action the host hooks (`useCritiqueStream`,
* `useCritiqueReplay`) dispatch when their gating prop changes
* (projectId switch, enabled flip, transcriptUrl swap). Lifts the
* reducer back to idle so the surrounding UI cannot render a stale
* critique that belonged to the previous project / transcript. Lives
* in the action union (not as a separate setter) so the reducer
* remains the single source of truth for state transitions.
*/
export interface CritiqueResetAction {
type: '__reset__';
}
export type CritiqueAction = PanelEvent | CritiqueResetAction;
export interface CritiqueDimScore {
name: string;
@@ -165,6 +178,13 @@ function ensurePanelist(
* launch consecutive runs without an explicit reset action.
*/
export function reduce(state: CritiqueState, action: CritiqueAction): CritiqueState {
// Host-dispatched reset (project change, enabled flip, transcript
// swap) always lifts us back to idle so a stale run from a prior
// project/transcript cannot bleed into the new context. Lefarcen +
// Siri-Ray + codex P2 on PR #1314.
if (action.type === '__reset__') {
return state.phase === 'idle' ? state : initialState;
}
// `run_started` is always accepted: from idle it boots a new run, and
// mid-stream it discards any prior state and reboots cleanly (the daemon
// does not multiplex two runs onto one SSE channel, so this only fires on
@@ -209,12 +229,23 @@ export function reduce(state: CritiqueState, action: CritiqueAction): CritiqueSt
}
switch (action.type) {
case 'panelist_open':
case 'panelist_open': {
// Materialize the round + an empty panelist view so TheaterStage
// can render the in-progress lane the instant the tag opens
// (lefarcen + Siri-Ray P2 on PR #1314): without this, a stream
// that emits only `panelist_open` after `run_started` would
// leave `rounds = []` and the UI would render no current
// round until a later `panelist_dim` arrived.
const rounds = withRound(state.rounds, action.round, (round) => {
ensurePanelist(round, action.role);
});
return {
...state,
rounds,
activePanelist: action.role,
activeRound: action.round,
};
}
case 'panelist_dim': {
const rounds = withRound(state.rounds, action.round, (round) => {
const panelist = ensurePanelist(round, action.role);

View File

@@ -51,11 +51,74 @@ export function critiqueEventsUrl(projectId: string): string {
* fails (missing runId, unknown type), we drop the frame and the
* reducer never sees it.
*/
/** Per-variant required-fields validator. `isPanelEvent` from contracts only
* checks `type` is known and `runId` is non-empty, so a frame like
* `{ type: 'ship', runId: 'r' }` would slip through to the reducer with every
* other field undefined and crash downstream code that calls
* `final.composite.toFixed(1)`. This second-pass filter enforces the shape
* of each variant before the action is dispatched (lefarcen + Siri-Ray +
* codex P2 on PR #1314). */
function hasValidVariantShape(event: PanelEvent): boolean {
switch (event.type) {
case 'run_started':
return typeof event.protocolVersion === 'number'
&& Array.isArray(event.cast) && event.cast.length > 0
&& event.cast.every((r) => typeof r === 'string')
&& typeof event.maxRounds === 'number'
&& typeof event.threshold === 'number'
&& typeof event.scale === 'number';
case 'panelist_open':
return typeof event.round === 'number' && typeof event.role === 'string';
case 'panelist_dim':
return typeof event.round === 'number'
&& typeof event.role === 'string'
&& typeof event.dimName === 'string'
&& typeof event.dimScore === 'number'
&& typeof event.dimNote === 'string';
case 'panelist_must_fix':
return typeof event.round === 'number'
&& typeof event.role === 'string'
&& typeof event.text === 'string';
case 'panelist_close':
return typeof event.round === 'number'
&& typeof event.role === 'string'
&& typeof event.score === 'number';
case 'round_end':
return typeof event.round === 'number'
&& typeof event.composite === 'number'
&& typeof event.mustFix === 'number'
&& (event.decision === 'continue' || event.decision === 'ship')
&& typeof event.reason === 'string';
case 'ship':
return typeof event.round === 'number'
&& typeof event.composite === 'number'
&& typeof event.status === 'string'
&& event.artifactRef !== null
&& typeof event.artifactRef === 'object'
&& typeof (event.artifactRef as { projectId?: unknown }).projectId === 'string'
&& typeof (event.artifactRef as { artifactId?: unknown }).artifactId === 'string'
&& typeof event.summary === 'string';
case 'degraded':
return typeof event.reason === 'string' && typeof event.adapter === 'string';
case 'interrupted':
return typeof event.bestRound === 'number' && typeof event.composite === 'number';
case 'failed':
return typeof event.cause === 'string';
case 'parser_warning':
return typeof event.kind === 'string' && typeof event.position === 'number';
}
}
export function sseToPanelEvent(eventName: CritiqueSseEventName, data: unknown): PanelEvent | null {
if (data === null || typeof data !== 'object') return null;
const type = eventName.slice('critique.'.length);
const candidate = { ...(data as Record<string, unknown>), type };
return isPanelEvent(candidate) ? candidate : null;
if (!isPanelEvent(candidate)) return null;
// Variant-level guard: a frame that passes the cheap predicate but
// is missing variant-specific fields would otherwise reach the
// reducer and crash the UI on `undefined.toFixed()` / `undefined.cast`
// (lefarcen + Siri-Ray + codex P2 on PR #1314).
return hasValidVariantShape(candidate) ? candidate : null;
}
/**

View File

@@ -1113,6 +1113,7 @@ export const en: Dict = {
'critiqueTheater.replaySpeed': 'Replay speed',
'critiqueTheater.readOnly': 'Read-only',
'critiqueTheater.shippedSummary': 'Shipped at round {round} · composite {composite}',
'critiqueTheater.interruptedSummary': 'Interrupted at round {round} · best composite {composite}',
'critiqueTheater.shippedBadge': 'Shipped',
'critiqueTheater.belowThresholdBadge': 'Below threshold',
'critiqueTheater.timedOutBadge': 'Timed out',

View File

@@ -1083,6 +1083,7 @@ export const zhCN: Dict = {
'critiqueTheater.replaySpeed': '回放速度',
'critiqueTheater.readOnly': '只读',
'critiqueTheater.shippedSummary': '已于第 {round} 轮发布 · 综合分 {composite}',
'critiqueTheater.interruptedSummary': '已于第 {round} 轮中断 · 最佳综合分 {composite}',
'critiqueTheater.shippedBadge': '已发布',
'critiqueTheater.belowThresholdBadge': '低于阈值',
'critiqueTheater.timedOutBadge': '已超时',

View File

@@ -1494,6 +1494,7 @@ export interface Dict {
'critiqueTheater.replaySpeed': string;
'critiqueTheater.readOnly': string;
'critiqueTheater.shippedSummary': string;
'critiqueTheater.interruptedSummary': string;
'critiqueTheater.shippedBadge': string;
'critiqueTheater.belowThresholdBadge': string;
'critiqueTheater.timedOutBadge': string;

View File

@@ -11953,7 +11953,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
}
.theater-lane[data-role="designer"] { border-left-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
.theater-lane[data-role="critic"] { border-left-color: color-mix(in srgb, var(--amber) 60%, var(--border)); }
.theater-lane[data-role="brand"] { border-left-color: color-mix(in srgb, var(--magenta, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="brand"] { border-left-color: color-mix(in srgb, var(--purple, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="a11y"] { border-left-color: color-mix(in srgb, var(--green, var(--accent)) 60%, var(--border)); }
.theater-lane[data-role="copy"] { border-left-color: color-mix(in srgb, var(--blue, var(--accent)) 60%, var(--border)); }
.theater-lane-head {

View File

@@ -72,7 +72,15 @@ describe('<TheaterCollapsed> (Phase 8)', () => {
render(<TheaterCollapsed state={state} />);
expect(screen.getByRole('status').getAttribute('data-phase')).toBe('interrupted');
expect(screen.getByText('Interrupted')).toBeTruthy();
expect(screen.getByText(/composite 7\.9/)).toBeTruthy();
// Lefarcen P3 on PR #1314: the interrupted branch must NOT reuse
// the shipped summary copy ("Shipped at round...") since the run
// was specifically NOT shipped. Pin the wording so a future
// refactor that swaps the keys back trips this assertion.
expect(screen.getByText(/Interrupted at round 1/)).toBeTruthy();
expect(screen.getByText(/best composite 7\.9/)).toBeTruthy();
expect(
screen.queryByText((_, node) => /Shipped at round/.test(node?.textContent ?? '')),
).toBeNull();
});
it('renders the failed phase with the cause-specific reason', () => {

View File

@@ -37,4 +37,27 @@ describe('<TheaterDegraded> (Phase 8)', () => {
expect(text.length).toBeGreaterThan(20);
}
});
it('assigns a unique heading id per instance via useId (PR #1314 review)', () => {
// Lefarcen P3: the previous hardcoded `id="theater-degraded-heading"`
// would produce duplicate ids when two chips render on the same page
// (chat history rendering several completed runs). Two chips must
// resolve their own `aria-labelledby` references.
render(
<div>
<TheaterDegraded reason="malformed_block" adapter="pi-rpc" />
<TheaterDegraded reason="missing_artifact" adapter="codex" />
</div>,
);
const sections = screen.getAllByRole('status');
expect(sections).toHaveLength(2);
const labelledByA = sections[0]!.getAttribute('aria-labelledby');
const labelledByB = sections[1]!.getAttribute('aria-labelledby');
expect(labelledByA).toBeTruthy();
expect(labelledByB).toBeTruthy();
expect(labelledByA).not.toBe(labelledByB);
// Each heading id is actually referenced by its own section.
expect(document.getElementById(labelledByA!)?.tagName).toBe('H3');
expect(document.getElementById(labelledByB!)?.tagName).toBe('H3');
});
});

View File

@@ -353,4 +353,37 @@ describe('useCritiqueReplay (Phase 7.3)', () => {
expect(gunzip).toHaveBeenCalledTimes(1);
expect(sink.state.phase).toBe('shipped');
});
it('resets reducer state to idle when transcriptUrl flips to null after a replay (PR #1314 review)', async () => {
// Lefarcen + Siri-Ray + codex P2: clearing the URL after a
// replay reached `shipped` previously only cleared meta + events
// + cursor. The reducer state stayed at `shipped`, so
// TheaterTranscript could render stale rounds while reporting
// `status: 'idle'`.
const sink: Sink = { state: { phase: 'idle' }, status: 'idle', error: null };
const { rerender } = render(
<Probe
url="/api/replay.ndjson"
speed="instant"
options={{ fetchTranscript: async () => ndjson(TRANSCRIPT) }}
sink={sink}
/>,
);
await waitFor(() => {
expect(sink.state.phase).toBe('shipped');
});
rerender(
<Probe
url={null}
speed="instant"
options={{ fetchTranscript: async () => ndjson(TRANSCRIPT) }}
sink={sink}
/>,
);
await waitFor(() => {
expect(sink.state.phase).toBe('idle');
});
expect(sink.status).toBe('idle');
});
});

View File

@@ -153,4 +153,55 @@ describe('useCritiqueStream (Phase 7.2)', () => {
expect(handles[0]!.closed).toBe(true);
expect(handles[1]!.closed).toBe(false);
});
it('resets reducer state to idle when projectId changes (PR #1314 review)', () => {
// Lefarcen + Siri-Ray + codex P2: a workspace switch from project
// A (which already streamed a critique) to project B must not
// leave project A's Theater state visible while waiting for B's
// run_started.
const { factory, handles } = makeFactory();
const sink: Harness = { state: { phase: 'idle' } };
const { rerender } = render(
<Probe projectId="proj-1" enabled factory={factory} sink={sink} />,
);
act(() => {
handles[0]!.send({
type: 'run_started',
runId: 'r1',
protocolVersion: 1,
cast: ['critic'],
maxRounds: 3,
threshold: 8,
scale: 10,
});
});
expect(sink.state.phase).toBe('running');
rerender(<Probe projectId="proj-2" enabled factory={factory} sink={sink} />);
expect(sink.state.phase).toBe('idle');
});
it('resets reducer state to idle when enabled flips to false mid-run', () => {
const { factory, handles } = makeFactory();
const sink: Harness = { state: { phase: 'idle' } };
const { rerender } = render(
<Probe projectId="proj-1" enabled factory={factory} sink={sink} />,
);
act(() => {
handles[0]!.send({
type: 'run_started',
runId: 'r1',
protocolVersion: 1,
cast: ['critic'],
maxRounds: 3,
threshold: 8,
scale: 10,
});
});
expect(sink.state.phase).toBe('running');
rerender(<Probe projectId="proj-1" enabled={false} factory={factory} sink={sink} />);
expect(sink.state.phase).toBe('idle');
expect(handles[0]!.closed).toBe(true);
});
});

View File

@@ -311,4 +311,62 @@ describe('Critique Theater reducer (Phase 7)', () => {
});
expect(s2).toBe(s1);
});
it('__reset__ lifts a running state back to idle (PR #1314 review)', () => {
// Project / transcript context changes dispatch this synthetic
// action so the next mount cannot see a stale prior run.
let s: CritiqueState = runningState();
s = reduce(s, {
type: 'panelist_must_fix', runId: RUN_ID, round: 1, role: 'critic', text: 'low contrast',
});
expect(s.phase).toBe('running');
s = reduce(s, { type: '__reset__' });
expect(s.phase).toBe('idle');
});
it('__reset__ lifts a terminal state back to idle too', () => {
let s: CritiqueState = runningState();
s = reduce(s, {
type: 'ship', runId: RUN_ID, round: 1, composite: 8.6, status: 'shipped',
artifactRef: { projectId: 'p1', artifactId: 'a1' }, summary: 'ok',
});
expect(s.phase).toBe('shipped');
s = reduce(s, { type: '__reset__' });
expect(s.phase).toBe('idle');
});
it('__reset__ on idle state returns the same reference (no allocation churn)', () => {
const next = reduce(initialState, { type: '__reset__' });
expect(next).toBe(initialState);
});
it('panelist_open materializes the round + empty panelist view (PR #1314 review)', () => {
// Before this fix, the reducer set activePanelist/activeRound but
// left `rounds = []`, so TheaterStage had no in-progress round
// cell to highlight until a later dim/must-fix/close event
// arrived. The protocol treats panelist_open as the tag-open
// event, so the UI must become visible at that point.
let s: CritiqueState = runningState();
s = reduce(s, { type: 'panelist_open', runId: RUN_ID, round: 1, role: 'critic' });
expect(s.phase).toBe('running');
if (s.phase !== 'running') return;
expect(s.rounds).toHaveLength(1);
expect(s.rounds[0]?.n).toBe(1);
expect(s.rounds[0]?.panelists.critic).toEqual({ dims: [], mustFixes: [] });
expect(s.activePanelist).toBe('critic');
expect(s.activeRound).toBe(1);
});
it('panelist_open does not stomp an existing round with prior panelist data', () => {
let s: CritiqueState = runningState();
s = reduce(s, {
type: 'panelist_dim', runId: RUN_ID, round: 1, role: 'designer',
dimName: 'composition', dimScore: 8, dimNote: 'tight',
});
s = reduce(s, { type: 'panelist_open', runId: RUN_ID, round: 1, role: 'critic' });
expect(s.phase).toBe('running');
if (s.phase !== 'running') return;
expect(s.rounds[0]?.panelists.designer?.dims).toHaveLength(1);
expect(s.rounds[0]?.panelists.critic).toEqual({ dims: [], mustFixes: [] });
});
});

View File

@@ -307,4 +307,85 @@ describe('sseToPanelEvent (Phase 7.2)', () => {
}),
).toBeNull();
});
it('drops a critique.ship frame missing variant-specific required fields (PR #1314 review)', () => {
// Lefarcen + Siri-Ray + codex P2: `isPanelEvent` only checks
// `type` and `runId`, so a frame like `{ runId: 'r' }` on the
// critique.ship channel would reach the reducer with composite,
// round, status, artifactRef all undefined. TheaterCollapsed
// would then call `final.composite.toFixed(1)` and crash.
expect(
sseToPanelEvent('critique.ship' as CritiqueSseEventName, { runId: 'r' }),
).toBeNull();
// Even with most fields, missing `summary` still rejects.
expect(
sseToPanelEvent('critique.ship' as CritiqueSseEventName, {
runId: 'r',
round: 1,
composite: 8.6,
status: 'shipped',
artifactRef: { projectId: 'p', artifactId: 'a' },
}),
).toBeNull();
});
it('accepts a fully-formed critique.ship frame', () => {
const action = sseToPanelEvent('critique.ship' as CritiqueSseEventName, {
runId: 'r',
round: 1,
composite: 8.6,
status: 'shipped',
artifactRef: { projectId: 'p', artifactId: 'a' },
summary: 'looks good',
});
expect(action?.type).toBe('ship');
});
it('drops a critique.degraded frame missing the adapter field', () => {
expect(
sseToPanelEvent('critique.degraded' as CritiqueSseEventName, {
runId: 'r',
reason: 'malformed_block',
}),
).toBeNull();
});
it('drops a critique.run_started frame with an empty cast', () => {
expect(
sseToPanelEvent('critique.run_started' as CritiqueSseEventName, {
runId: 'r',
protocolVersion: 1,
cast: [],
maxRounds: 3,
threshold: 8,
scale: 10,
}),
).toBeNull();
});
it('drops a critique.panelist_dim frame with non-numeric dimScore', () => {
expect(
sseToPanelEvent('critique.panelist_dim' as CritiqueSseEventName, {
runId: 'r',
round: 1,
role: 'critic',
dimName: 'contrast',
dimScore: 'eight',
dimNote: 'borderline',
}),
).toBeNull();
});
it('drops a critique.round_end frame with an unknown decision value', () => {
expect(
sseToPanelEvent('critique.round_end' as CritiqueSseEventName, {
runId: 'r',
round: 1,
composite: 7.4,
mustFix: 2,
decision: 'maybe',
reason: 'unclear',
}),
).toBeNull();
});
});