Files
apes-sidebar/tests/changes-tab.spec.tsx
T
lhh010 16d8df2e93 Merge remote-tracking branch 'origin/main' into feat/changes-md-reading
# Conflicts:
#	src/client/changes/DiffPane.tsx
#	src/client/locales-ar.ts
#	src/client/locales-de.ts
#	src/client/locales-fr.ts
#	src/client/locales-hi.ts
#	src/client/locales-id.ts
#	src/client/locales-it.ts
#	src/client/locales-ja.ts
#	src/client/locales-ko.ts
#	src/client/locales-nl.ts
#	src/client/locales-pl.ts
#	src/client/locales-pt.ts
#	src/client/locales-ru.ts
#	src/client/locales-sv.ts
#	src/client/locales-th.ts
#	src/client/locales-tr.ts
#	src/client/locales-vi.ts
#	src/client/locales-zh-HK.ts
#	src/client/locales-zh-MO.ts
#	src/client/locales-zh-TW.ts
2026-09-04 14:13:34 +08:00

205 lines
8.4 KiB
TypeScript

/**
* The unified changes tab shell: the lens switcher swaps the Git lens for
* the session lens, a Git-lens file row opens the shared preview pane
* (loaded through the mocked git API, rendered by the shared DiffFiles
* stack), and the session ops ride the mocked `changes.ops` poll — the
* event poll, the op fold and the badge cache all live in the tab.
*/
// @vitest-environment jsdom
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 { ChangesTab, opCountOf } from '../src/client/changes/ChangesTab.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
import type { Context } from '../src/context-types.ts'
import type { SidebarTab } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const MAIN = 'C:/repo/main'
/** One synthetic tool/call + tool/result pair (write of one file). */
function writeEvents(seq: number, path: string): Array<{ type: string; seq: number; time: number; data: Record<string, unknown> }> {
return [
{ type: 'tool/call', seq, time: seq, data: { name: 'write', callId: `w${seq}`, arguments: JSON.stringify({ file_path: path, content: 'body' }) } },
{ type: 'tool/result', seq: seq + 1, time: seq + 1, data: { message: { source: { kind: 'tool', callId: `w${seq}` }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'ok' }] }] } } },
]
}
function fakeContext(): Context {
return {
get: () => undefined,
} as unknown as Context
}
function mount(root: Root, tab: SidebarTab = { id: 'git', type: 'git', title: 'Changes' }): void {
act(() => {
root.render(createElement(ChangesTab, {
ctx: fakeContext(),
store: createSidebarStore(),
scope: { sessionId: 'session', cwd: MAIN },
tab,
visible: false,
onOpenFile: () => {},
onOpenDiff: () => {},
}))
})
}
function mockGit(entries: Array<{ path: string; xy: string }>): void {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([
{ path: MAIN, branch: 'main', current: true, changes: entries.length },
] as GitWorktree[])
vi.spyOn(api, 'gitStatus').mockResolvedValue({
isRepo: true, branch: 'main', entries,
} as GitStatusResult)
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
}
async function flushEffects(): Promise<void> {
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })
}
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
describe('ChangesTab', () => {
it('renders the git lens by default and previews a file row in the pane', async () => {
mockGit([{ path: 'src/a.ts', xy: ' M' }])
vi.spyOn(api, 'gitDiff').mockResolvedValue({
diff: [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+new',
].join('\n'),
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// Git lens: the file row from the mocked status (badge letter + path).
const row = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('src/a.ts'))
expect(row).toBeDefined()
// Clicking the row previews inline (no diff tab minted): the shared
// renderer draws the added row.
await act(async () => { row!.click() })
await flushEffects()
expect(container.textContent).toContain('new')
// The preview survives a lens switch (it is a dock, not lens state).
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
expect(sessionButton).toBeDefined()
await act(async () => { sessionButton!.click() })
expect(container.textContent).toContain('src/a.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('folds the polled session events into session-lens rows and the badge cache', async () => {
mockGit([])
const ops = vi.spyOn(api, 'changesOps')
.mockResolvedValue({ events: [...writeEvents(1, 'src/a.ts'), ...writeEvents(3, 'src/b.ts')], lastSeq: 4 })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// The tab pulled once on mount and published the op count.
expect(ops).toHaveBeenCalled()
expect(opCountOf('session')).toBe(2)
const group = container.querySelector('[role="group"]')
const buttons = [...group!.querySelectorAll<HTMLButtonElement>('button')]
await act(async () => { buttons[1]!.click() })
// Two files grouped, newest first; the write chip carries its count.
expect(container.textContent).toContain('src/b.ts')
expect(container.textContent).toContain('src/a.ts')
const chips = [...container.querySelectorAll('button[aria-pressed]')]
expect(chips.some(chip => /写入\s*2|Write\s*2/.test(chip.textContent ?? ''))).toBe(true)
// The git lens (its branch picker) is unmounted by the switch.
expect(container.querySelector('select')).toBeNull()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('renders a .md read op in reading mode through the shared MarkdownText', async () => {
mockGit([])
const md = ['# Title', '', '| A | B |', '| --- | --- |', '| 1 | 2 |', '', '![pic](./img.png)', ''].join('\n')
const numbered = md.split('\n').map((line, i) => String(i + 1) + ': ' + line).join('\n')
const envelope = '<content>\n' + numbered + '\n</content>'
vi.spyOn(api, 'changesOps').mockResolvedValue({
events: [
{ type: 'tool/call', seq: 1, time: 1, data: { name: 'read', callId: 'r1', arguments: JSON.stringify({ file_path: 'C:/repo/main/notes.md' }) } },
{ type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'r1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: envelope }] }] } } },
],
lastSeq: 2,
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// Session lens: open the read op's preview.
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
await act(async () => { sessionButton!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="read"]')
expect(row).toBeDefined()
expect(container.textContent).toContain('notes.md')
await act(async () => { row!.click() })
await flushEffects()
// Reading toggle: raw first (line-numbered rows), then rendered GFM.
const toggle = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => (button.textContent ?? '').match(/阅读|Reading/))
expect(toggle).toBeDefined()
expect(container.querySelector('h1')).toBeNull()
await act(async () => { toggle!.click() })
await flushEffects()
expect(container.querySelector('h1')?.textContent).toContain('Title')
expect(container.querySelector('table')).not.toBeNull()
// The local image destination was rewritten to the media route.
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toContain('/sidebar/file?')
// Toggling back restores the raw line view.
const rawToggle = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => (button.textContent ?? '').match(/原文|Raw/))
await act(async () => { rawToggle!.click() })
await flushEffects()
expect(container.querySelector('h1')).toBeNull()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})