Files
apes-sidebar/tests/editor-refresh.spec.tsx
T
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

178 lines
6.7 KiB
TypeScript

/**
* EditorHost refresh behaviors (issue #167): the manual refresh button
* re-runs the load (A), returning from edit to preview reloads unless the
* draft is dirty or the save failed (B), and a preview-mode save reloads on
* the 'saved' edge (C). A mock viewer reports a hoisted toolbar so the host
* chrome renders exactly like the real text editor.
*/
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect, useState } 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 { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
setupReactAct()
const fsRead = vi.fn()
vi.mock('../src/client/api.ts', () => ({
api: { fsRead: (...args: unknown[]) => fsRead(...args) },
mediaUrl: () => '',
}))
/** One fsRead call counter reset per test. */
function reads(): number {
return fsRead.mock.calls.length
}
interface MockViewerProps {
initialMode?: 'preview' | 'edit'
dirty?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolbarState?: (state: any) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolbarControls?: (controls: any) => void
}
/** A viewer that hoists a real toolbar (mode toggle + save state). */
function MockTextViewer({ initialMode = 'preview', dirty = false, onToolbarState, onToolbarControls }: MockViewerProps) {
const [mode, setMode] = useState(initialMode)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
useEffect(() => {
onToolbarState?.({ modes: true, mode, dirty, editable: true, saveState })
}, [mode, saveState, dirty, onToolbarState])
useEffect(() => {
onToolbarControls?.({
setMode,
// A synchronous save (saving then saved in one batch) drives the edge.
save: () => { setSaveState('saving'); setSaveState('saved') },
})
return () => { onToolbarControls?.(null) }
}, [onToolbarControls])
return createElement('div', null, `mock-${mode}`)
}
function setup(initialMode: 'preview' | 'edit' = 'preview', dirty = false): {
ctx: Context
fileTab: () => SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
service.registerFileViewer({
id: 'mock-text',
exts: ['ts'],
priority: 0,
fetchStrategy: 'fsRead',
component: (props: FileViewerProps) => createElement(MockTextViewer, {
initialMode,
dirty,
onToolbarState: props.onToolbarState,
onToolbarControls: props.onToolbarControls,
}),
})
store.setSession('editor-home-session')
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
const ctx = {
betterSidebar: service,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
get: (name: string) => name === 'betterSidebar' ? service : undefined,
} as unknown as Context
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
return { ctx, fileTab }
}
function mount(ctx: Context, tab: () => SidebarTab): { container: HTMLDivElement; unmount: () => void } {
return renderRoot(createElement(EditorHost, {
ctx, store: ctx.betterSidebar as never, scope: { sessionId: 'editor-home-session' },
tab: tab(), expanded: [], revealed: [], onToggleDir: () => {}, onReferenceFile: () => {},
}))
}
/** The header's refresh button (locale-dependent aria-label), or null. */
function refreshButton(container: HTMLDivElement): HTMLButtonElement | null {
return container.querySelector('button[aria-label="Refresh"], button[aria-label="刷新"]')
}
function click(button: HTMLButtonElement): void {
act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
}
beforeEach(() => {
fsRead.mockReset()
fsRead.mockResolvedValue({ kind: 'text', content: 'hello', truncated: false })
})
describe('EditorHost refresh (issue #167)', () => {
it('A: the refresh button renders for a toolbar-reporting viewer and re-runs the load', async () => {
const { ctx, fileTab } = setup()
const { container, unmount } = mount(ctx, fileTab)
try {
// The initial load ran once; settle the async fsRead.
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const refresh = refreshButton(container)
expect(refresh).not.toBeNull()
click(refresh!)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(2)
} finally {
unmount()
}
})
it('B: returning from edit to preview reloads (fresh content shows after save)', async () => {
const { ctx, fileTab } = setup('edit')
const { container, unmount } = mount(ctx, fileTab)
try {
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const preview = Array.from(container.querySelectorAll('button'))
.find(button => button.textContent === 'Preview')!
click(preview)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(2)
} finally {
unmount()
}
})
it('B: a dirty draft suppresses the edit→preview reload (the draft would be dropped)', async () => {
const { ctx, fileTab } = setup('edit', true)
const { container, unmount } = mount(ctx, fileTab)
try {
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const preview = Array.from(container.querySelectorAll('button'))
.find(button => button.textContent === 'Preview')!
click(preview)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
} finally {
unmount()
}
})
it('C: a preview-mode save reloads on the saved edge', async () => {
const { ctx, fileTab } = setup('preview')
const { container, unmount } = mount(ctx, fileTab)
try {
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const save = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement
click(save)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(2)
} finally {
unmount()
}
})
})