fix(web): align the plan summary's usable-content rule with the tool

planSummary treated whitespace-only content as nameable, so a rejected call
whose args survive verbatim rendered a blank active clause beside a live +N.
The tool's own rule is trimmed non-empty; the row now uses it.

Also hoists the duplicated DSH_SNAPSHOT refresh flag out of the two assembled
snapshot files into their shared assembled-boot scaffolding.
This commit is contained in:
Chinesezjc
2026-08-06 13:31:40 +08:00
parent 5663c9f507
commit 60153324ce
5 changed files with 24 additions and 13 deletions
+7
View File
@@ -124,3 +124,10 @@ export function mountAssembledApp(): void {
export function hasClass(el: Element, name: string): boolean { export function hasClass(el: Element, name: string): boolean {
return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
} }
/**
* Whether this run rewrites its golden instead of comparing against it, set by
* the snapshot gate's `DSH_SNAPSHOT` mode (`record` re-runs the scenarios from
* scratch, `refresh` re-derives the expected text from the existing ones).
*/
export const REFRESHING_GOLDEN = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
+2 -3
View File
@@ -18,10 +18,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts'
const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt')
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
installAssembledBootEnv() installAssembledBootEnv()
@@ -72,7 +71,7 @@ describe('assembled search card', () => {
expect(grepRow.querySelector('[data-search]')).not.toBeNull() expect(grepRow.querySelector('[data-search]')).not.toBeNull()
}, { timeout: 10_000 }) }, { timeout: 10_000 })
const shape = cardShape(grepRow) const shape = cardShape(grepRow)
if (refreshing) { if (REFRESHING_GOLDEN) {
mkdirSync(dirname(EXPECTED), { recursive: true }) mkdirSync(dirname(EXPECTED), { recursive: true })
writeFileSync(EXPECTED, shape) writeFileSync(EXPECTED, shape)
} }
+2 -3
View File
@@ -14,10 +14,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts'
const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt') const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt')
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
installAssembledBootEnv() installAssembledBootEnv()
@@ -63,7 +62,7 @@ describe('assembled todo surfaces', () => {
if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
const shape = todoShape(row, panel) const shape = todoShape(row, panel)
if (refreshing) { if (REFRESHING_GOLDEN) {
mkdirSync(dirname(EXPECTED), { recursive: true }) mkdirSync(dirname(EXPECTED), { recursive: true })
writeFileSync(EXPECTED, shape) writeFileSync(EXPECTED, shape)
} }
@@ -38,17 +38,19 @@ export interface PlanSummary {
* the first `in_progress` item and counts the remaining active ones, so a * the first `in_progress` item and counts the remaining active ones, so a
* parallel plan reports how many tasks are running rather than naming one and * parallel plan reports how many tasks are running rather than naming one and
* hiding the others. `activeContent` is null when nothing is in progress, or * hiding the others. `activeContent` is null when nothing is in progress, or
* when the first active item carries no usable content — model JSON may. The * when the first active item's content is missing, mistyped, or blank once
* row then renders the counts alone rather than falling back to the generic * trimmed — the tool's own rule for usable content, applied here because a
* tool summary: the counts are already known to be good, and the active-item * rejected call keeps its args verbatim. The row then renders the counts alone
* clause is the only part an unusable name costs. * rather than falling back to the generic tool summary: the counts are already
* known to be good, and the active-item clause is the only part an unusable
* name costs.
* @param todos - the whole list, in model order. * @param todos - the whole list, in model order.
* @returns the done/total counts and the two summary halves. * @returns the done/total counts and the two summary halves.
*/ */
export function planSummary(todos: readonly PlanItemLike[]): PlanSummary { export function planSummary(todos: readonly PlanItemLike[]): PlanSummary {
const active = todos.filter(t => t.status === 'in_progress') const active = todos.filter(t => t.status === 'in_progress')
const first = active[0]?.content const first = active[0]?.content
const named = typeof first === 'string' && first !== '' const named = typeof first === 'string' && first.trim() !== ''
return { return {
done: todos.filter(t => t.status === 'completed').length, done: todos.filter(t => t.status === 'completed').length,
total: todos.length, total: todos.length,
@@ -62,12 +62,16 @@ describe('planSummary', () => {
}) })
it('has no hint when the first active item carries no usable content (model JSON)', () => { it('has no hint when the first active item carries no usable content (model JSON)', () => {
// Unvalidated args: a missing, mistyped, or empty content yields no hint — // Unvalidated args: a missing, mistyped, empty, or whitespace-only content
// and no orphan count, even with a second active item to count. // yields no hint — and no orphan count, even with a second active item to
// count. Whitespace-only is the tool's own rejection rule (trimmed
// non-empty), and a rejected call keeps its args verbatim.
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 }) .toMatchObject({ activeContent: null, activeExtra: 0 })
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull() expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull() expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 })
}) })
it('is empty-safe', () => { it('is empty-safe', () => {