Skip to content

Commit c78ea5a

Browse files
committed
feat(qa/scenarios): refuse to export an app whose record has a not-measured turn
The scorecard counts a turn that rests on a refused seed row in neither passed nor total, so its half can still read `pass`. The exporter now reads every Run half's questions and operations and refuses the app when any of them is marked not measured, naming each such turn. A previous export of that app is removed like any other no-longer-passing app. Pinned by tests: a refused seed row (crm contact ct4) keeps every half verdict at `pass` and still fails judgeRecord; an operation marked not measured is its own reason; exportGallery refuses the app and removes its earlier export. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
1 parent 7d21d93 commit c78ea5a

4 files changed

Lines changed: 82 additions & 6 deletions

File tree

‎packages/qa/scenarios/src/export/pass.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
* first Build and Run rows are `not-run` by construction (the mode installs
2020
* a verified build and builds nothing first), so they are not asked. A
2121
* template mode that was skipped holds `not-run` in every row and fails.
22+
* - No turn may be NOT MEASURED, in any Run half the record holds (the
23+
* first Run and the iterate re-run, both modes), questions and operations
24+
* alike. The scorecard leaves a not-measured turn (one that reads a seed
25+
* row the app refused) out of both passed and total, so its half can still
26+
* read `pass`; the gallery is trust-first, and a turn nobody measured is a
27+
* claim nobody proved. Each one is a reason of its own.
2228
*/
2329

2430
import type { ScenarioRunRecord, Verdict } from './record.js';
@@ -40,7 +46,7 @@ export interface PassJudgement {
4046
passed: boolean;
4147
/** Every owed half with its verdict, in order. */
4248
halves: HalfVerdict[];
43-
/** Why it did not pass, one line per reason; empty when it passed. */
49+
/** Why it did not pass, one line per reason (a failing half, or a not-measured turn); empty when it passed. */
4450
reasons: string[];
4551
}
4652

@@ -59,5 +65,19 @@ export function judgeRecord(record: ScenarioRunRecord): PassJudgement {
5965
if (verdict !== 'pass') reasons.push(`${id}: ${verdict}`);
6066
}
6167
}
68+
for (const m of record.cardRun.modes) {
69+
const runs = [
70+
['run', m.run],
71+
['iterate-run', m.iterate?.run],
72+
] as const;
73+
for (const [phase, half] of runs) {
74+
for (const q of half?.questions ?? []) {
75+
if (q.notMeasured) reasons.push(`${card}.${m.mode}.${phase} question ${q.question}${q.persona ? ` as ${q.persona}` : ''}: not measured — ${q.notMeasured}`);
76+
}
77+
for (const o of half?.operations ?? []) {
78+
if (o.notMeasured) reasons.push(`${card}.${m.mode}.${phase} operation ${o.operation} as ${o.persona}: not measured — ${o.notMeasured}`);
79+
}
80+
}
81+
}
6282
return { passed: reasons.length === 0, halves, reasons };
6383
}

‎packages/qa/scenarios/src/export/record.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ const QuestionResultSchema = z.looseObject({
3131
error: z.string().optional(),
3232
});
3333

34-
const RunHalfSchema = z.looseObject({ questions: z.array(QuestionResultSchema) });
34+
const OperationResultSchema = z.looseObject({
35+
operation: z.string().min(1),
36+
persona: z.string().min(1),
37+
notMeasured: z.string().optional(),
38+
});
39+
40+
const RunHalfSchema = z.looseObject({ questions: z.array(QuestionResultSchema), operations: z.array(OperationResultSchema) });
3541

3642
const ModeRunSchema = z.looseObject({
3743
mode: z.enum(['scratch', 'template']),

‎packages/qa/scenarios/test/export/exporter.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,35 @@ describe('judgeRecord — "passed every half", read from the record', () => {
103103
});
104104
});
105105

106+
describe('judgeRecord — ⛔ a not-measured turn is not a pass, even under a `pass` verdict', () => {
107+
it('a refused seed row leaves every half `pass` in the record, and the record still does not pass', async () => {
108+
// ct4 is a contact: every question on contacts, and every operation that names it, is not measured.
109+
const { record } = await runCrm({ refuse: ['democrm_contact/ct4'] });
110+
const verdicts = (record.results as Array<{ id: string; verdict: string }>).filter((r) => /scratch\.(run-ask|run-operations)$/.test(r.id)).map((r) => r.verdict);
111+
expect(verdicts).toEqual(['pass', 'pass']); // the scorecard counts not-measured turns in neither passed nor total
112+
const j = judgeRecord(asRecord(record));
113+
expect(j.passed).toBe(false);
114+
expect(j.halves.every((h) => h.verdict === 'pass')).toBe(true);
115+
expect(j.reasons.length).toBeGreaterThan(0);
116+
for (const r of j.reasons) expect(r).toMatch(/^crm_account_with_contacts\.(scratch|template)\.(run|iterate-run) (question|operation) \S+.*: not measured — seed: /);
117+
// Every Run half the record holds is read: scratch first run, scratch re-run, template re-run.
118+
expect(new Set(j.reasons.map((r) => r.split(' ')[0]))).toEqual(
119+
new Set([`${CARD_ID}.scratch.run`, `${CARD_ID}.scratch.iterate-run`, `${CARD_ID}.template.iterate-run`]),
120+
);
121+
});
122+
123+
it('an operation marked not measured is a reason of its own', async () => {
124+
const { record } = await runCrm();
125+
const cardRun = record.cardRun as { modes: Array<{ mode: string; run: { operations: Array<Record<string, unknown>> } }> };
126+
const scratch = cardRun.modes.find((m) => m.mode === 'scratch')!;
127+
const [first, ...rest] = scratch.run.operations;
128+
const edited = { ...record, cardRun: { ...cardRun, modes: cardRun.modes.map((m) => (m === scratch ? { ...m, run: { ...m.run, operations: [{ ...first, notMeasured: 'seed: needs democrm_account ac1, which the app refused' }, ...rest] } } : m)) } };
129+
expect(judgeRecord(asRecord(edited)).reasons).toEqual([
130+
`${CARD_ID}.scratch.run operation ${String(first!.operation)} as ${String(first!.persona)}: not measured — seed: needs democrm_account ac1, which the app refused`,
131+
]);
132+
});
133+
});
134+
106135
describe('exportGallery — one passing, generic app', () => {
107136
it('writes the apps/<id>/ layout: source, run record, scorecard, transcript, README, and the index', async () => {
108137
const { out, fx, opts } = await setup();
@@ -211,6 +240,20 @@ describe('exportGallery — one passing, generic app', () => {
211240
expect(existsSync(join(out, 'apps', 'hand_made'))).toBe(true);
212241
});
213242

243+
it('an app whose newest record has a not-measured turn is refused, and a previous export of it removed', async () => {
244+
const { out, runs, opts } = await setup();
245+
await exportGallery(opts());
246+
const later = await runCrm({ date: '2026-12-01', refuse: ['democrm_contact/ct4'] });
247+
writeRecord(runs, later.record);
248+
const s = await exportGallery(opts({ recordings: [later.recording] }));
249+
expect(s.exported).toEqual([]);
250+
expect(s.refused[0]!.reasons[0]).toMatch(/did not pass every half/);
251+
expect(s.refused[0]!.reasons.slice(1).every((r) => r.includes(': not measured — seed: '))).toBe(true);
252+
expect(s.removed).toEqual([{ id: CARD_ID, why: expect.stringContaining('not measured') }]);
253+
expect(existsSync(join(out, 'apps', CARD_ID))).toBe(false);
254+
expect(parseIndex(readFileSync(join(out, 'index.json'), 'utf8')).apps).toEqual([]);
255+
});
256+
214257
it('the newest record decides, even when an older one passed', async () => {
215258
const { runs } = await setup();
216259
const later = await runCrm({ date: '2026-12-01', iterateRun: false });

‎packages/qa/scenarios/test/export/fixture.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,22 @@ export interface FixtureRun {
236236
recording: Recording;
237237
}
238238

239-
/** One run of the crm card, both modes; `iterateRun: false` leaves the iterate Run halves not run. */
240-
export async function runCrm(opts: { work?: string; date?: string; iterateRun?: boolean; modes?: Array<'scratch' | 'template'> } = {}): Promise<FixtureRun> {
239+
/** One run of the crm card, both modes; `iterateRun: false` leaves the iterate Run halves not run; `refuse` makes every booted app refuse those seed rows. */
240+
export async function runCrm(
241+
opts: { work?: string; date?: string; iterateRun?: boolean; modes?: Array<'scratch' | 'template'>; /** Seed rows every booted app refuses, `object/id`. */ refuse?: string[] } = {},
242+
): Promise<FixtureRun> {
241243
const scenario = loadCrm();
242244
const work = opts.work ?? tempDir('work');
243-
let current = new FakeApp();
245+
const fresh = () => {
246+
const app = new FakeApp();
247+
for (const r of opts.refuse ?? []) app.refuse.add(r);
248+
return app;
249+
};
250+
let current = fresh();
244251
const ops = fakeOps(work, () => current);
245252
const boot = ops.boot.bind(ops);
246253
ops.boot = async (dir) => {
247-
current = new FakeApp();
254+
current = fresh();
248255
return boot(dir);
249256
};
250257
const driver = new RecordingDriver(new ScriptedDriver(scenario, ops, () => current));

0 commit comments

Comments
 (0)