Files
dsh-context/tests/client/components/fileCard.spec.ts
T
Bowen Liang 4d144147a0 refactor(client): import React members directly, dropping the central react module
Every client file reached hooks and element types through the central
require seam (src/client/react.ts) and spelled them React.useState /
ReactNS.ReactElement. Import the members directly instead — named value
imports for hooks, inline type-only imports for element types — and let
the bundler own the single require('react'), which is exactly what the
platform module table answers.

This needs JSX off the classic runtime (it required a React identifier
in every tsx scope): tsconfig moves to jsx: react-jsx, so the bundle
sources jsx from 'react/jsx-runtime', already seeded by every supported
harness (verified against the dsh-v0.1.2-rc.1 platform.ts). The bundle
smoke's fake module table gains the specifier to stay a faithful shell
stand-in. h() survives as a local createElement alias at the slot
render-prop seams and the lightbox portal imports createPortal from
react-dom directly.

Verified: the built client.js requires react, react/jsx-runtime, and
react-dom exactly once each; the compat lane (bundle smoke + the
real-baseline matrix) passes against the fresh artifacts; the full gate
stays green with per-file coverage at 100%.
2026-09-07 08:59:03 +08:00

556 lines
26 KiB
TypeScript
Raw 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.
// FileCard (src/client/components/fileCard.tsx) rendered with real React:
// purpose/form filter chips, path search, per-file rows with count badges
// and line deltas, expandable operation logs, the op-level locate hook,
// workspace-relative path display, and the system-open affordance.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } 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, text, click } from '../helpers/kit'
const kit = makeKit()
const settings = createContextSettings()
const FileCard = makeFileCard(kit, settings)
const T0 = 1700000000000
function fileOp(seq: number, kind: FileOp['kind'], tool: string, over: Partial<FileOp> = {}): FileOp {
return { seq, kind, tool, time: T0 + seq, err: false, added: 0, removed: 0, path: '', ...over }
}
function entry(path: string, over: Partial<FileEntry>): FileEntry {
return { path, form: 'text', reads: 0, writes: 0, searches: 0, added: 0, removed: 0, errs: 0, ops: [], ...over }
}
/** Five files exercising every row anatomy: split paths, all badges, deltas both ways, failures, a directory, an image. */
function richActivity(over: Partial<FileActivity> = {}): FileActivity {
return {
entries: [
entry('/src/a.ts', {
reads: 2,
writes: 2,
added: 2,
removed: 3,
errs: 1,
ops: [
fileOp(7, 'read', 'read'),
fileOp(5, 'write', 'edit', { added: 2, removed: 1 }),
fileOp(4, 'write', 'edit', { removed: 2, err: true }),
fileOp(3, 'read', 'read', { time: undefined }),
],
}),
entry('/shots/ui.png', { form: 'image', reads: 1, ops: [fileOp(9, 'read', 'read_image')] }),
entry('/src/', { form: 'dir', searches: 1, ops: [fileOp(6, 'search', 'grep', { detail: 'needle' })] }),
entry('solo.md', { writes: 1, added: 5, ops: [fileOp(8, 'write', 'write', { added: 5, time: undefined })] }),
entry('/var/many.log', {
reads: 9,
ops: Array.from({ length: 9 }, (_, i) => fileOp(28 - i, 'read', 'read')),
}),
],
totals: {
read: { files: 3, ops: 12 },
write: { files: 2, ops: 3 },
search: { files: 1, ops: 1 },
image: { files: 1, ops: 1 },
added: 7,
removed: 3,
},
...over,
}
}
function chipByLabel(container: ParentNode, label: string): HTMLElement {
const hit = queryAll(container, '.lc-fa-ctl .lc-gran-btn').find(b => text(b).startsWith(label))
if (hit === undefined) throw new Error(`chip not found: ${label}`)
return hit
}
function rowByTitle(container: ParentNode, title: string): HTMLElement {
const hit = queryAll(container, '.lc-fa-row').find(r => r.title === title)
if (hit === undefined) throw new Error(`row not found: ${title}`)
return hit
}
async function typeSearch(container: ParentNode, value: string): Promise<void> {
const input = query<HTMLInputElement>(container, 'input.lc-fa-search')
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
describe('FileCard — empty state', () => {
test('no file ops renders the empty state without controls', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'Turn 1 · Step 1',
}))
assert.ok(text(m.container).includes('No file reads, writes, or searches'))
assert.ok(text(m.container).includes('Turn 1 · Step 1'))
assert.equal(queryAll(m.container, '.lc-fa-ctl').length, 0)
await m.unmount()
})
})
describe('FileCard — header, rows, and filters', () => {
test('renders chips with op counts, the meta strip, and fully-dressed rows', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'Up to latest' }))
// Chip op counts: all = 12 + 3 + 1.
assert.ok(text(chipByLabel(m.container, 'All')).includes('16'))
assert.ok(text(chipByLabel(m.container, 'Read')).includes('12'))
assert.ok(text(chipByLabel(m.container, 'Images')).includes('1'))
// Meta strip: file count + aggregate delta (+7 −3).
const meta = query(m.container, '.lc-fa-meta')
assert.ok(text(meta).includes('5 files'))
assert.ok(text(meta).includes('+7'))
assert.ok(text(meta).includes('−3'))
assert.ok(query(meta, '.lc-fa-meta-tip').textContent?.includes('estimated') === true)
const rows = queryAll(m.container, '.lc-fa-row')
assert.equal(rows.length, 5)
// Default order is most-active first: many.log (9 ops) → a.ts (4) → the one-op files by recency.
assert.deepEqual(rows.map(r => r.title), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
// Path split: muted dir + bold base; every badge and the row delta.
const first = rows[0]
assert.ok(first.querySelector('.lc-fa-path em')?.textContent === '/var/')
assert.ok(first.querySelector('.lc-fa-path b')?.textContent === 'many.log')
assert.ok(text(first).includes('9')) // read ×9 badge
assert.equal(queryAll(first, '.lc-fa-badge').length, 1) // read only
assert.ok(first.querySelector('.lc-fa-time') !== null)
const aRow = rowByTitle(m.container, '/src/a.ts')
assert.ok(aRow.querySelector('.lc-fa-path em')?.textContent === '/src/')
assert.ok(aRow.querySelector('.lc-fa-path b')?.textContent === 'a.ts')
assert.equal(queryAll(aRow, '.lc-fa-badge').length, 2) // read + write
assert.ok(text(aRow).includes('+2') && text(aRow).includes('−3'))
assert.ok(aRow.querySelector('.lc-br-err-dot') !== null)
assert.ok(aRow.title === '/src/a.ts')
// The directory row trims its trailing slash for display.
const dirRow = rows.find(r => r.title === '/src/')
assert.ok(dirRow !== undefined)
assert.ok(dirRow.querySelector('.lc-fa-path b')?.textContent === 'src')
// The extension-less row relativizes with no workspace known: a './' dir span, and no badges beyond its own kind.
const solo = rows.find(r => r.title === 'solo.md')
assert.ok(solo !== undefined)
assert.ok(solo.querySelector('.lc-fa-path em')?.textContent === './')
assert.ok(solo.querySelector('.lc-fa-path b')?.textContent === 'solo.md')
assert.ok(text(solo).includes('+5') && !text(solo).includes('−'))
// solo.md's only op is timeless: the row drops its time cell.
assert.ok(solo.querySelector('.lc-fa-time') === null)
await m.unmount()
})
test('purpose and form chips isolate rows; re-clicking restores all', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
const paths = () => queryAll(m.container, '.lc-fa-row').map(r => r.title)
// Kind chips always carry their badge-color class; the neutral chips don't.
assert.ok(chipByLabel(m.container, 'Read').className.includes('lc-fa-chip-read'))
assert.ok(chipByLabel(m.container, 'Written').className.includes('lc-fa-chip-write'))
assert.ok(chipByLabel(m.container, 'Searched').className.includes('lc-fa-chip-search'))
assert.ok(!chipByLabel(m.container, 'All').className.includes('lc-fa-chip-'))
assert.ok(!chipByLabel(m.container, 'Images').className.includes('lc-fa-chip-'))
await click(chipByLabel(m.container, 'Written'))
assert.ok(chipByLabel(m.container, 'Written').className.includes('lc-gran-on'))
assert.deepEqual(paths(), ['/src/a.ts', 'solo.md'])
await click(chipByLabel(m.container, 'Searched'))
assert.deepEqual(paths(), ['/src/'])
await click(chipByLabel(m.container, 'Images'))
assert.deepEqual(paths(), ['/shots/ui.png'])
await click(chipByLabel(m.container, 'Read'))
assert.deepEqual(paths(), ['/var/many.log', '/src/a.ts', '/shots/ui.png'])
await click(chipByLabel(m.container, 'Read'))
assert.equal(paths().length, 5)
await m.unmount()
})
test('the count sort keys on the selected kind once a kind chip is active', async () => {
// x.log is op-heavy overall (6 ops) but read-light (1); y.ts is read-heavy (2 of 2 ops);
// idx.md and pat.txt split the search counts 3 vs 1.
const activity: FileActivity = {
entries: [
entry('/logs/x.log', {
reads: 1,
writes: 5,
ops: [
fileOp(6, 'write', 'edit'),
fileOp(5, 'write', 'edit'),
fileOp(4, 'write', 'edit'),
fileOp(3, 'write', 'edit'),
fileOp(2, 'write', 'edit'),
fileOp(1, 'read', 'read'),
],
}),
entry('/code/y.ts', { reads: 2, ops: [fileOp(8, 'read', 'read'), fileOp(7, 'read', 'read')] }),
entry('/src/idx.md', {
searches: 3,
ops: [fileOp(11, 'search', 'glob'), fileOp(10, 'search', 'grep'), fileOp(9, 'search', 'grep')],
}),
entry('/src/pat.txt', { searches: 1, ops: [fileOp(12, 'search', 'grep')] }),
],
totals: {
read: { files: 2, ops: 3 },
write: { files: 1, ops: 5 },
search: { files: 2, ops: 4 },
image: { files: 0, ops: 0 },
added: 0,
removed: 0,
},
}
const m = await mount(h(FileCard, { activity, scope: 'live' }))
const titles = () => queryAll(m.container, '.lc-fa-row').map(r => r.title)
// 'count' over everything: total ops lead.
assert.deepEqual(titles(), ['/logs/x.log', '/src/idx.md', '/code/y.ts', '/src/pat.txt'])
// The read chip narrows AND the count sort re-keys on reads: 2 reads beat 1.
await click(chipByLabel(m.container, 'Read'))
assert.deepEqual(titles(), ['/code/y.ts', '/logs/x.log'])
// The write chip leaves the one write-heavy file, still write-keyed.
await click(chipByLabel(m.container, 'Written'))
assert.deepEqual(titles(), ['/logs/x.log'])
// The search chip re-keys on searches: 3 beat 1.
await click(chipByLabel(m.container, 'Searched'))
assert.deepEqual(titles(), ['/src/idx.md', '/src/pat.txt'])
await m.unmount()
})
test('path search narrows rows and reports no matches', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
await typeSearch(m.container, 'A.TS')
assert.deepEqual(queryAll(m.container, '.lc-fa-row').map(r => r.title), ['/src/a.ts'])
await typeSearch(m.container, 'zzz')
assert.equal(queryAll(m.container, '.lc-fa-row').length, 0)
assert.ok(text(m.container).includes('No files match the current filters'))
await typeSearch(m.container, '')
assert.equal(queryAll(m.container, '.lc-fa-row').length, 5)
await m.unmount()
})
test('the sort toggle defaults to most-active and switches to latest and path', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
const titles = () => queryAll(m.container, '.lc-fa-row').map(r => r.title)
const sortBtns = queryAll(m.container, '.lc-fa-sort .lc-gran-btn')
assert.equal(sortBtns.length, 3)
// Count order by default: 9 ops → 4 ops → one-op files by recency.
assert.ok(sortBtns[0].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
// Latest order: many.log (seq 28) → ui.png (9) → solo.md (8) → a.ts (7) → /src/ (6).
await click(sortBtns[1])
assert.ok(sortBtns[1].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/var/many.log', '/shots/ui.png', 'solo.md', '/src/a.ts', '/src/'])
// Path order: ascending over full paths (the dir row precedes its children).
await click(sortBtns[2])
assert.ok(sortBtns[2].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/shots/ui.png', '/src/', '/src/a.ts', '/var/many.log', 'solo.md'])
await click(sortBtns[0])
assert.ok(sortBtns[0].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
await m.unmount()
})
test('the mount-time default sort comes from the plugin settings', async () => {
const titles = (container: ParentNode) => queryAll(container, '.lc-fa-row').map(r => r.title)
const sortBtns = (container: ParentNode) => queryAll(container, '.lc-fa-sort .lc-gran-btn')
settings.set('defaultFileSort', 'latest')
let m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[1].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/var/many.log', '/shots/ui.png', 'solo.md', '/src/a.ts', '/src/'])
await m.unmount()
settings.set('defaultFileSort', 'path')
m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[2].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/shots/ui.png', '/src/', '/src/a.ts', '/var/many.log', 'solo.md'])
await m.unmount()
// The stored preference resets the default; in-card toggling never writes back.
settings.set('defaultFileSort', 'count')
m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[0].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
await click(sortBtns(m.container)[1])
assert.deepEqual(settings.store.getSnapshot().fileSort, 'count')
await m.unmount()
})
})
describe('FileCard — expansion and locate', () => {
test('a row click expands its op log; an op click locates it; re-click collapses', async () => {
const located: number[] = []
const m = await mount(h(FileCard, {
activity: richActivity(),
scope: 'live',
onLocate: op => { located.push(op.seq) },
}))
const row = rowByTitle(m.container, '/src/a.ts')
await click(row)
const ops = queryAll(m.container, '.lc-fa-op')
assert.equal(ops.length, 4)
// Op anatomy: time, tool chip, per-op delta, failure dot, and the timeless op's dash.
assert.ok(text(ops[0]).includes('read'))
assert.ok(ops[0].className.includes('lc-fa-op-link'))
assert.ok(text(ops[1]).includes('+2') && text(ops[1]).includes('−1'))
assert.ok(text(ops[2]).includes('−2') && !text(ops[2]).includes('+'))
assert.ok(ops[2].querySelector('.lc-br-err-dot') !== null)
assert.ok(text(ops[3]).includes('—'))
// The op click jumps — and does not collapse the row (no bubbling).
await click(ops[1])
assert.deepEqual(located, [5])
assert.ok(query(m.container, '.lc-fa-ops') !== null)
await click(rowByTitle(m.container, '/src/a.ts'))
assert.equal(queryAll(m.container, '.lc-fa-ops').length, 0)
await m.unmount()
})
test('a search op shows its pattern detail; the expanded log lists every op', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
// The directory row: one grep op with its searched pattern.
await click(rowByTitle(m.container, '/src/'))
assert.ok(text(query(m.container, '.lc-fa-ops')).includes('needle'))
// The nine-read log renders all nine rows — no cap, no overflow line
// (one expanded file at a time: this click collapsed the directory row).
await click(rowByTitle(m.container, '/var/many.log'))
const many = query(m.container, '.lc-fa-ops')
assert.equal(queryAll(many, '.lc-fa-op').length, 9)
await m.unmount()
})
test('a nested PTC op tells why (its program); a meta search op shows its hit count', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [
entry('/src/a.ts', {
reads: 2,
ops: [
fileOp(9, 'read', 'read', { parent: 30, program: 'Read failing test and its fixture' }),
fileOp(7, 'read', 'read'),
],
}),
entry('/src/b.ts', { searches: 1, ops: [fileOp(10, 'search', 'grep', { detail: 'needle', hits: 3 })] }),
],
totals: { read: { files: 1, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 1, ops: 1 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
await click(rowByTitle(m.container, '/src/a.ts'))
assert.ok(text(query(m.container, '.lc-fa-ops')).includes('Read failing test and its fixture'))
await click(rowByTitle(m.container, '/src/a.ts')) // collapse
await click(rowByTitle(m.container, '/src/b.ts'))
const ops = text(query(m.container, '.lc-fa-ops'))
assert.ok(ops.includes('needle'))
assert.ok(ops.includes('3 hits'))
await m.unmount()
})
test('a code file renders its language badge; an emoji file keeps its glyph', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [
entry('/src/main.ts', { reads: 1, ops: [fileOp(9, 'read', 'read')] }),
entry('/notes.md', { reads: 1, ops: [fileOp(8, 'read', 'read')] }),
],
totals: { read: { files: 2, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
// The badge: TS label on the language fill, readable text color inline.
const tsRow = rowByTitle(m.container, '/src/main.ts')
const badge = query(tsRow, '.lc-fa-lang')
assert.ok(badge !== undefined)
assert.equal(badge.textContent, 'TS')
assert.equal(query(tsRow, '.lc-fa-form').getAttribute('title'), 'TypeScript')
const style = badge.getAttribute('style') ?? ''
assert.ok(style.includes('background: rgb(49, 120, 198)'), style)
assert.ok(style.includes('color: rgb(255, 255, 255)'), style)
// The markdown row keeps its emoji glyph with the bucket name as the title.
const mdRow = rowByTitle(m.container, '/notes.md')
assert.equal(queryAll(mdRow, '.lc-fa-lang').length, 0)
assert.ok(text(mdRow).includes('📝'))
assert.equal(query(mdRow, '.lc-fa-form').getAttribute('title'), 'Markup document')
await m.unmount()
})
test('a read op shows its line footprint — exact window or estimate; the time rides the right edge', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [entry('/src/a.ts', {
reads: 2,
ops: [fileOp(7, 'read', 'read', { read: { start: 41, count: 30 } }), fileOp(6, 'read', 'read', { read: { count: 80, est: true } })],
})],
totals: { read: { files: 1, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
await click(rowByTitle(m.container, '/src/a.ts'))
const ops = queryAll(m.container, '.lc-fa-op')
// The exact read: `>> count` with the range as the title.
const exact = queryAll(ops[0], '.lc-fa-read')
assert.equal(exact.length, 1)
assert.equal(exact[0].textContent, '>>30')
assert.equal(exact[0].getAttribute('title'), 'Read lines 41–70')
// The estimate keeps the ≈ form and its own title.
const est = queryAll(ops[1], '.lc-fa-read')
assert.equal(est.length, 1)
assert.equal(est[0].textContent, '≈80')
assert.equal(est[0].getAttribute('title'), 'Lines read, estimated from the limit argument')
// The time is the row's last cell, pushed to the right edge (formatted, not the timeless dash).
const time = query(ops[1], '.lc-fa-op-time')
assert.notEqual(time.textContent, '—')
assert.ok((time.textContent ?? '').includes(':'))
await m.unmount()
})
test('without a locate hook the op lines render inert', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
await click(queryAll(m.container, '.lc-fa-row')[0])
assert.equal(queryAll(m.container, 'button.lc-fa-op').length, 0)
assert.ok(queryAll(m.container, 'div.lc-fa-op').length > 0)
await m.unmount()
})
test('no line activity anywhere drops the aggregate delta pill', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [entry('/a.ts', { reads: 1, ops: [fileOp(1, 'read', 'read')] })],
totals: { read: { files: 1, ops: 1 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
assert.equal(queryAll(m.container, '.lc-fa-meta-delta').length, 0)
await m.unmount()
})
})
describe('FileCard — workspace display and system open', () => {
/** Entries exercising every display form against one workspace root. */
function workspaceActivity(): FileActivity {
return {
entries: [
entry('/repo/src/a.ts', { reads: 1, ops: [fileOp(5, 'read', 'read')] }),
entry('/etc/hosts', { reads: 1, ops: [fileOp(4, 'read', 'read')] }),
entry('rel/b.md', { writes: 1, ops: [fileOp(3, 'write', 'write')] }),
entry('needle', { searches: 1, pattern: true, ops: [fileOp(2, 'search', 'grep', { detail: 'needle' })] }),
entry('./dot/c.ts', { reads: 1, ops: [fileOp(1, 'read', 'read')] }),
],
totals: {
read: { files: 3, ops: 3 },
write: { files: 1, ops: 1 },
search: { files: 1, ops: 1 },
image: { files: 0, ops: 0 },
added: 0,
removed: 0,
},
}
}
function rowOf(container: ParentNode, title: string): HTMLElement {
const hit = queryAll(container, '.lc-fa-row').find(r => r.title === title)
if (hit === undefined) throw new Error(`row not found: ${title}`)
return hit
}
const baseOf = (row: HTMLElement): HTMLElement => query(row, '.lc-fa-path b')
test('workspace paths display ./-relative; pattern rows and outside paths stay verbatim', async () => {
const m = await mount(h(FileCard, { activity: workspaceActivity(), scope: 'live', workspace: '/repo' }))
// Inside the workspace: './'-relative dir + base.
const a = rowOf(m.container, '/repo/src/a.ts')
assert.ok(a.querySelector('.lc-fa-path em')?.textContent === './src/')
assert.ok(baseOf(a).textContent === 'a.ts')
// An absolute path outside the workspace keeps its verbatim form.
const hosts = rowOf(m.container, '/etc/hosts')
assert.ok(hosts.querySelector('.lc-fa-path em')?.textContent === '/etc/')
// A bare-relative path displays './'-prefixed.
const rel = rowOf(m.container, 'rel/b.md')
assert.ok(rel.querySelector('.lc-fa-path em')?.textContent === './rel/')
// A pattern-as-target search row shows the pattern, never relativized.
const pat = rowOf(m.container, 'needle')
assert.equal(pat.querySelector('.lc-fa-path em'), null)
assert.ok(baseOf(pat).textContent === 'needle')
// A './'-prefixed path keeps its form.
const dot = rowOf(m.container, './dot/c.ts')
assert.ok(dot.querySelector('.lc-fa-path em')?.textContent === './dot/')
await m.unmount()
})
test('the file name opens on the system; pattern and unresolvable rows render inert', async () => {
const opened: string[] = []
const m = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
workspace: '/repo',
onOpen: p => { opened.push(p) },
}))
// A workspace file resolves to its absolute path and opens without expanding the row.
const a = baseOf(rowOf(m.container, '/repo/src/a.ts'))
assert.ok(a.className.includes('lc-fa-file'))
assert.equal(a.getAttribute('title'), 'Open on your system')
await click(a)
assert.deepEqual(opened, ['/repo/src/a.ts'])
assert.equal(queryAll(m.container, '.lc-fa-ops').length, 0)
// An outside absolute path hands through verbatim.
await click(baseOf(rowOf(m.container, '/etc/hosts')))
assert.deepEqual(opened, ['/repo/src/a.ts', '/etc/hosts'])
// Pattern rows are not files: inert bold text.
const pat = baseOf(rowOf(m.container, 'needle'))
assert.equal(pat.className.includes('lc-fa-file'), false)
// A './'-prefixed path has no root to resolve against: inert.
const dot = baseOf(rowOf(m.container, './dot/c.ts'))
assert.equal(dot.className.includes('lc-fa-file'), false)
await m.unmount()
})
test('without an opener (or without the workspace) the file names render inert', async () => {
// No onOpen prop: nothing clickable even with the workspace known.
const m1 = await mount(h(FileCard, { activity: workspaceActivity(), scope: 'live', workspace: '/repo' }))
assert.equal(queryAll(m1.container, '.lc-fa-file').length, 0)
await m1.unmount()
// No workspace: only the absolute rows resolve; relative ones stay inert.
const opened: string[] = []
const m2 = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
onOpen: p => { opened.push(p) },
}))
assert.equal(queryAll(m2.container, '.lc-fa-file').length, 2)
await click(baseOf(rowOf(m2.container, 'rel/b.md')))
assert.deepEqual(opened, [])
await m2.unmount()
})
})
describe('FileCard — the split generation detail states', () => {
test('a pending detail read shows the loading note instead of the empty claim', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'live',
state: 'loading',
onRetry: () => {},
}))
assert.ok(text(m.container).includes('Loading history'))
assert.ok(!text(m.container).includes('No file reads'))
await m.unmount()
})
test('a failed detail read arms the retry button; without one the empty claim stays', async () => {
let retries = 0
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'live',
state: 'failed',
onRetry: () => { retries++ },
}))
await click(query(m.container, '.lc-br-retry'))
assert.equal(retries, 1)
const inert = await mount(h(FileCard, { activity: richActivity({ entries: [] }), scope: 'live', state: 'failed' }))
assert.ok(text(inert.container).includes('No file reads'), 'no retry wired — the plain empty state')
await inert.unmount()
await m.unmount()
})
})