Files
bowenliang123 a3f572244b feat(client): drop the collapsed file row's error dot
Failures now surface only on the expanded op rows. The collapsed row's
aggregate tally added little on its own (its edit ops already flag the failing
tool call), so FileEntry.errs — which existed only for that dot — and the
files.errs strings go with it; each op keeps its own err flag for the expanded
dots.
2026-10-19 10:39:53 +08:00

146 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { createElement as h } from 'react'
import { afterAll, beforeAll, describe, test, vi } from 'vitest'
import { makeFileCard } from '../../../src/client/components/fileCard'
import type { FileActivity, FileEntry, FileOp } from '../../../src/client/fileActivity'
import { createContextSettings } from '../../../src/client/settings'
import { makeKit, mount, query, queryAll, until } from '../helpers/kit'
const kit = makeKit()
const FileCard = makeFileCard(kit, createContextSettings())
const PITCH = 41
const GAP = 2
const T0 = 1700000000000
/** Some rows wrap to two lines: windowing must fall back to the full list then. */
let wrapRows = false
let originals: [string, PropertyDescriptor | undefined][] = []
/** jsdom lays nothing out: give the list a 500×320 box and the rows a fixed 41px pitch so the card's own
* windowing path (wide + unexpanded) runs for real: measure → window → scroll. */
beforeAll(() => {
// A no-op observer covers the card's ResizeObserver path; the list is measured on mount anyway.
vi.stubGlobal('ResizeObserver', class {
observe = (): void => {}
disconnect = (): void => {}
})
originals = (['clientWidth', 'clientHeight', 'getBoundingClientRect'] as const)
.map(name => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as [string, PropertyDescriptor | undefined])
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement): number {
if (this.classList.contains('lc-fa-list')) return 500
const own = Object.getOwnPropertyDescriptor(Element.prototype, 'clientWidth')?.get
return own === undefined ? 0 : own.call(this)
},
})
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get(this: HTMLElement): number {
if (this.classList.contains('lc-fa-list')) return 320
const own = Object.getOwnPropertyDescriptor(Element.prototype, 'clientHeight')?.get
return own === undefined ? 0 : own.call(this)
},
})
const rect = Object.getOwnPropertyDescriptor(Element.prototype, 'getBoundingClientRect')!.value as () => DOMRect
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
value(this: HTMLElement): DOMRect {
if (this.classList.contains('lc-fa-list')) {
return { left: 0, top: 0, width: 500, height: 320, right: 500, bottom: 320, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
}
if (this.classList.contains('lc-fa-item')) {
const items = [...(this.parentElement?.querySelectorAll('.lc-fa-item') ?? [])]
const i = items.indexOf(this)
const heightOf = (j: number): number => (wrapRows && j % 7 === 0 ? PITCH + 20 : PITCH) - GAP
let top = 0
for (let j = 0; j < i; j++) top += heightOf(j) + GAP
return {
left: 0, top, width: 500, height: heightOf(i),
right: 500, bottom: top + heightOf(i), x: 0, y: top, toJSON: () => ({}),
} as DOMRect
}
return rect.call(this) as DOMRect
},
})
})
afterAll(() => {
vi.unstubAllGlobals()
for (const [name, desc] of originals) {
if (desc === undefined) delete (HTMLElement.prototype as unknown as Record<string, unknown>)[name]
else Object.defineProperty(HTMLElement.prototype, name, desc)
}
})
function entry(index: number): FileEntry {
const op: FileOp = { seq: index, kind: 'read', tool: 'read', time: T0 + index, err: false, added: 0, removed: 0, path: '' }
return { path: `/src/file-${String(index).padStart(3, '0')}.ts`, form: 'text', reads: 1, writes: 0, searches: 0, added: 0, removed: 0, ops: [op] }
}
function activityOf(count: number): FileActivity {
return {
entries: Array.from({ length: count }, (_, i) => entry(i)),
totals: {
read: { files: count, ops: count }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 },
added: 0, removed: 0,
},
}
}
const props = { activity: activityOf(60), state: 'ready' as const, onRetry: undefined }
describe('FileCard row windowing', () => {
test('renders a measured window with full-height pads and follows the scroll', async () => {
const m = await mount(h(FileCard, props))
try {
const list = query<HTMLElement>(m.container, '.lc-fa-list')
await until(() => queryAll(m.container, '.lc-fa-item').length < 60, 'the list windows after measuring')
const rendered = queryAll(m.container, '.lc-fa-item').length
assert.ok(rendered < 60 && rendered > 8)
const pads = queryAll<HTMLElement>(m.container, '.lc-row-pad')
assert.equal(pads.length, 1)
assert.ok(Number.parseFloat(pads[0].style.height) > 0)
assert.equal(list.clientHeight, 320) // the list keeps its box; the full height lives in the pads
list.scrollTop = 900
await new Promise(resolve => setTimeout(resolve, 40))
list.dispatchEvent(new Event('scroll'))
await until(() => queryAll<HTMLElement>(m.container, '.lc-row-pad').length === 2, 'a top pad appears after scrolling')
const afterScroll = queryAll(m.container, '.lc-fa-item').length
assert.ok(afterScroll < 60 && afterScroll >= 8)
// Pads reproduce the skipped rows' pitch, letting the flex gap supply one slot between pad and row.
const [topPad, bottomPad] = queryAll<HTMLElement>(m.container, '.lc-row-pad')
assert.equal(topPad.style.height, `${13 * PITCH - GAP}px`)
assert.equal(bottomPad.style.height, `${21 * PITCH - GAP}px`)
} finally {
await m.unmount()
}
})
test('mixed wrapped row heights keep the full list so the scroll geometry stays truthful', async () => {
wrapRows = true
const m = await mount(h(FileCard, props))
try {
await until(() => queryAll(m.container, '.lc-fa-item').length === 60, 'the full list renders')
assert.equal(queryAll(m.container, '.lc-row-pad').length, 0, 'no spacers on a non-uniform list')
} finally {
wrapRows = false
await m.unmount()
}
})
test('an expanded row falls back to the full list, then windows again when closed', async () => {
const m = await mount(h(FileCard, props))
try {
await until(() => queryAll(m.container, '.lc-fa-item').length < 60, 'windowed')
const first = query<HTMLElement>(m.container, '.lc-fa-row')
first.click()
await until(() => queryAll(m.container, '.lc-fa-item').length === 60, 'expanded row shows the full list')
first.click()
await until(() => queryAll(m.container, '.lc-fa-item').length < 60, 'closing the row re-windows')
} finally {
await m.unmount()
}
})
})