Files
apes-sidebar/tests/diff-fold-expand.spec.tsx
T
haruhi 900654b8fd fix(diff): git lens 折叠行接入按需展开,修复点击无反应
- src/client/changes/DiffPane.tsx / src/client/DiffTab.tsx: 按 SidebarDiffRef 构造 fold loader——commit 侧 <hash>^ 与 <hash>;staged 侧 HEAD 与 :0;unstaged 侧 :0 与工作区 fsRead(失败/二进制降级空串);per 文件内容 Promise 缓存,目标或刷新 tick 变化时重置
- fallback 换侧时记录 effectiveStaged,loader 依实际渲染侧取数,修正切片行号错位
- src/client/locales.ts 及 19 个 locales-*.ts: 新增 changesFoldLoading / changesFoldUnavailable 全量补译(共 20 文件 21 处)
- tests/diff-fold-expand.spec.tsx: 展开/折叠/缓存去重、失败降级、无 loader 保持 inert、session 路径回归

背景:git 提交/工作区 diff 折叠行承诺「点击展开」却永远不可点,与 session lens 行为不一致;接入状态机后承诺为真,session 路径与现有文案零变化。
设计 docs/plans/2026-09-07-git-fold-expand-design.md §4.5-4.6。
2026-09-07 23:51:10 +08:00

144 lines
5.9 KiB
TypeScript

// @vitest-environment jsdom
/**
* The git fold expansion path: DiffFiles forwards a resolveFold loader to
* DiffRows, whose first click on a rows-less git gap fold fetches the hidden
* rows (loading/failed markers in between) and later clicks toggle; folds
* that carry rows (the session-op path) expand directly, and gaps without a
* loader stay inert markers — the pre-fix behavior for op diffs.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { DiffFiles } from '../src/client/diff/DiffFiles.tsx'
import { DiffRows } from '../src/client/diff/DiffRows.tsx'
import { buildDiffSegments, diffLines, type DiffFile, type FoldSegment } from '../src/client/diff/rows.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// A git-shaped two-hunk patch: the gap between the hunks (old/new lines
// 2..11) is a fold WITHOUT rows — expandable only through a resolveFold.
const gapDiff = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+new',
'@@ -12 +12 @@',
'-tail',
'+tail2',
].join('\n')
// The hidden context rows a loader slices out of both sides' contents.
const hiddenRows = Array.from({ length: 10 }, (_, i) => ({
kind: 'context' as const,
oldLine: i + 2,
newLine: i + 2,
text: `ctx-${String(i + 2)}`,
}))
afterEach(() => { document.body.innerHTML = '' })
describe('DiffFiles on-demand fold expansion', () => {
it('expands a git gap fold through resolveFold, then collapses back', async () => {
const resolveFold = vi.fn((_file: DiffFile, _segment: FoldSegment) => Promise.resolve(hiddenRows))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffFiles, { diff: gapDiff, resolveFold })) })
const fold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(fold).not.toBeNull()
expect(container.textContent).not.toContain('ctx-5')
await act(async () => { fold!.click() })
expect(resolveFold).toHaveBeenCalledOnce()
expect(container.textContent).toContain('ctx-5')
// The loader received the gap fold itself (line ranges on board).
const segment = resolveFold.mock.calls[0]![1]
expect(segment.kind).toBe('fold')
expect(segment.oldStart).toBe(2)
// Collapse: the marker is back; the fetch never repeats.
const expanded = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { expanded!.click() })
expect(container.textContent).not.toContain('ctx-5')
expect(resolveFold).toHaveBeenCalledOnce()
// Re-expanding resolves from the per-fold cache (no second fetch).
const collapsed = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { collapsed!.click() })
expect(container.textContent).toContain('ctx-5')
expect(resolveFold).toHaveBeenCalledOnce()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('degrades to the unavailable marker when the loader rejects', async () => {
const resolveFold = vi.fn(() => Promise.reject(new Error('boom')))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffFiles, { diff: gapDiff, resolveFold })) })
const fold = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { fold!.click() })
// The degraded fold promises no interaction.
expect(container.querySelector('[data-expandable]')).toBeNull()
expect(container.textContent).not.toContain('ctx-5')
// jsdom's navigator.language is en-US → the en copy.
expect(container.textContent).toContain('Context unavailable')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('keeps git gap folds inert without a resolver (session surfaces unchanged)', () => {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffFiles, { diff: gapDiff })) })
expect(container.querySelector('[data-expandable]')).toBeNull()
// The quiet marker still shows the hidden line count.
expect(container.textContent).toContain('10 lines')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('expands session folds (rows carried) directly, without any loader', async () => {
// buildDiffSegments output — the session-op path: folds carry their rows,
// so they toggle on click exactly as before the git expansion existed.
const rows = diffLines(
'a\nb\nc\nd\ne\nX\nf\ng\nh\ni\nj\nk\nl\n',
'a\nb\nc\nd\ne\nY\nf\ng\nh\ni\nj\nk\nl\n',
)
const segments = buildDiffSegments(rows)
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => { root.render(createElement(DiffRows, { segments })) })
const fold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(fold).not.toBeNull()
// 'j' is hidden in the tail fold (the marker copy carries no 'j').
expect(container.textContent).not.toContain('j')
await act(async () => { fold!.click() })
expect(container.textContent).toContain('j')
const expanded = container.querySelector<HTMLElement>('[data-expandable="true"]')
await act(async () => { expanded!.click() })
expect(container.textContent).not.toContain('j')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})