Files
apes-sidebar/tests/markdown-manual-refresh.spec.tsx
Menghuan1918 223b2545d9 refactor(shell): retire the plugin's own right panel and free windows
DSH 0.1.5 owns the right column (its native Sidebar hosts every tab type
this plugin registers), so the plugin's own right panel, its width drag /
new-session seed prefs, and the free-window feature are gone. The bottom
workbench is the only plugin-owned surface left, and its expand/collapse
control is registered into DSH's session-header utilities list slot (the
header's corner seat belongs to the native sidebar's own control).

- state: one `bottomSplits` tree; `makeDefaultState()` takes no arguments;
  `toggleBottomPanel` / `openTabInBottomPane` are the only landing
  reducers; `setBottomHeight` and `sanitizeState` share the
  `CONVERSATION_MIN` cap (a persisted tall height no longer snaps on the
  first drag).
- service: every landing in the workbench (creation or focus) expands it;
  `SIDEBAR_FEATURES` drops `'floatWindows'`; the open/close/activate tab
  pools cover the single tree.
- shell: `Sidebar.tsx` renders only the bottom workbench; the toggle lives
  in `sidebar/bottom-toggle.tsx`; layout-push pushes height only (no width,
  no `--dsh-sidebar-width`).
- prefs: `openByDefault` / `defaultWidthPercent` / `changesDiffFloat`
  removed from the schema, defaults, client parser, settings page, and all
  20 dictionaries; the retired keys in stored documents are ignored.
- tests: free-window specs deleted, panel/float assertions dropped,
  remaining suites adapted (124 files / 1287 tests); the mount lane asserts
  the header toggle and sweeps the native sidebar's guide page.
- docs: README / README_EN / guide §0,§11 / AGENTS test map.
2026-09-09 15:18:06 +08:00

166 lines
5.7 KiB
TypeScript

/**
* Markdown refresh behavior: external edits are picked up only when the user
* explicitly clicks the refresh button, and unsaved drafts stay protected.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { api } from '../src/client/api.ts'
import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
setupReactAct()
function toolbar(dirty: boolean) {
return {
modes: true,
mode: 'preview' as const,
dirty,
editable: true,
saveState: 'idle' as const,
}
}
let markDirty: (() => void) | undefined
/** A tiny viewer that exposes the host toolbar and renders the loaded source. */
function FakeMarkdownViewer(props: FileViewerProps) {
useEffect(() => {
props.onToolbarControls?.({ setMode: () => {}, save: () => {} })
props.onToolbarState?.(toolbar(false))
markDirty = () => { props.onToolbarState?.(toolbar(true)) }
return () => {
markDirty = undefined
props.onToolbarControls?.(null)
}
// Only the callback identities must re-wire; content is read at render time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onToolbarControls, props.onToolbarState])
return createElement('div', { 'data-testid': 'markdown-content' }, props.content ?? '')
}
function setup(): {
store: ReturnType<typeof createSidebarStore>
ctx: Context
tab: SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
service.registerFileViewer({
id: 'markdown',
exts: ['md'],
fetchStrategy: 'fsRead',
component: FakeMarkdownViewer,
})
store.setSession('markdown-manual-refresh-session')
const home = allLeaves(store.getSnapshot().state!.bottomSplits)
.flatMap(leaf => leaf.tabs)
.find(candidate => candidate.type === 'editor')!
const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' }
const ctx = {
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
} as unknown as Context
return { store, ctx, tab }
}
function mount(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: SidebarTab) {
return renderRoot(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' },
tab,
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
afterEach(() => {
markDirty = undefined
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Markdown manual refresh', () => {
it('does not poll after an external edit and reloads only after clicking refresh', async () => {
vi.useFakeTimers()
let disk = 'before'
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
kind: 'text', content: disk, truncated: false,
}))
const { store, ctx, tab } = setup()
const view = mount(ctx, store, tab)
try {
await flush()
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
const initialReads = read.mock.calls.length
disk = 'after'
await act(async () => {
vi.advanceTimersByTime(3000)
await Promise.resolve()
await Promise.resolve()
})
expect(read.mock.calls.length).toBe(initialReads)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
expect(refresh).not.toBeNull()
act(() => { refresh!.click() })
await flush()
expect(read.mock.calls.length).toBeGreaterThan(initialReads)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('after')
} finally {
view.unmount()
}
})
it('protects dirty content until the user confirms manual refresh', async () => {
let disk = 'before'
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
kind: 'text', content: disk, truncated: false,
}))
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { store, ctx, tab } = setup()
const view = mount(ctx, store, tab)
try {
await flush()
expect(markDirty).toBeDefined()
act(() => { markDirty?.() })
await flush()
const before = read.mock.calls.length
disk = 'external'
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
expect(refresh).not.toBeNull()
act(() => { refresh!.click() })
await flush()
expect(confirm).toHaveBeenCalled()
expect(read.mock.calls.length).toBe(before)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
confirm.mockReturnValue(true)
act(() => { refresh!.click() })
await flush()
expect(read.mock.calls.length).toBeGreaterThan(before)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('external')
} finally {
view.unmount()
}
})
})