refactor: replace overloaded surface terminology

This commit is contained in:
Turtle
2026-07-24 19:54:25 +08:00
parent c172faed37
commit 0c708cb10d
626 changed files with 1396 additions and 1397 deletions

View File

@@ -1,15 +1,15 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end against the terminal thin
// shape: the strict session surface (views triple, draft mirror), the
// API: the strict session API (views triple, draft mirror), the
// provide-channel input face (machine-sink submit choreography incl.
// optimistic clear + failure restore), the resident surface (selectWorkspace
// optimistic clear + failure restore), the resident API (selectWorkspace
// draft carrying), the composer-bar stop face, openDetails = select action +
// layout orchestration, and the closeDetails details surface. Complements
// layout orchestration, and the closeDetails details API. Complements
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
// axis). History opening is NOT an inject concern — the runtime sessions
// service opens on watch (sessions-service.spec.ts owns that behavior).
//
// The inject surfaces are read off the ledger entries deliberately (typed at
// The inject APIs are read off the ledger entries deliberately (typed at
// this spec's own contract): these cases pin factory choreography the UI
// guards would mask. Rendering-path acceptance lives in
// chat-toolview-slot.spec.tsx.
@@ -75,30 +75,30 @@ async function bench() {
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
runtime.slots.entries(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const conversationApi = (id: SessionId) => {
const entry = entryOf('conversation.session')
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
id, instance.actions)
return { instance, injected }
}
const conversationHeaderSurface = (id: SessionId) => {
const conversationHeaderApi = (id: SessionId) => {
const entry = entryOf('conversation.session.header')
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
id, instance.actions)
return { instance, injected }
}
const residentSurface = (id: SessionId | undefined) => {
const residentApi = (id: SessionId | undefined) => {
const entry = entryOf('conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
}
const composerSurface = (id: SessionId | undefined) => {
const composerApi = (id: SessionId | undefined) => {
const entry = entryOf('conversation.composer.bar')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
}
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const chatViewApi = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
@@ -106,7 +106,7 @@ async function bench() {
return { instance, injected }
}
/** Materialize the input provide contribution the way the runtime does. */
const inputSurface = (id: SessionId) => {
const inputApi = (id: SessionId) => {
const info = runtime.sessions.provideInfo(id)!
const state = info.hooks['input'] as {
getSnapshot: () => { draft: string }
@@ -120,21 +120,21 @@ async function bench() {
}
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
sessionFake, layoutFake,
}
}
describe('conversation slot inject surface', () => {
it('assembles the thin surface side-effect-free', async () => {
describe('conversation slot inject API', () => {
it('assembles the thin API side-effect-free', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const { injected } = b.conversationApi(ROOT)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
const chatView = b.chatViewSurface(ROOT)
const chatView = b.chatViewApi(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
chatView.injected.forkAt(17)
@@ -149,8 +149,8 @@ describe('conversation slot inject surface', () => {
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const { state, actions } = b.inputSurface(ROOT)
const { injected } = b.conversationApi(ROOT)
const { state, actions } = b.inputApi(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit()
@@ -176,16 +176,16 @@ describe('conversation slot inject surface', () => {
await new Promise(r => setTimeout(r, 0))
expect(state.getSnapshot().draft).toBe('typed during flight')
// The provide contribution is idempotent per session: one shell identity.
expect(b.inputSurface(ROOT).state).toBe(state)
expect(b.inputApi(ROOT).state).toBe(state)
// The draft mirror rides the conversation inject face.
const mirrored: string[] = []
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
actions.setDraft('mirrored text')
expect(mirrored).toEqual(['mirrored text'])
unbind()
// Stop failure is swallowed (promptError owns the surface).
// Stop failure is swallowed (promptError owns the display).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
b.composerSurface(ROOT).stop!()
b.composerApi(ROOT).stop!()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
@@ -216,20 +216,20 @@ describe('conversation slot inject surface', () => {
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const { instance, injected } = b.chatViewSurface(ROOT)
const { instance, injected } = b.chatViewApi(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
// The chat view shares the conversation entry's store instance: selection
// writes land where the skeleton and details read.
const conv = b.conversationSurface(ROOT)
const conv = b.conversationApi(ROOT)
expect(conv.instance).toBe(instance)
await b.runtime.dispose()
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
const b = await bench()
const { injected } = b.chatViewSurface(ROOT)
const { injected } = b.chatViewApi(ROOT)
injected.openFile('src/a.ts')
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
@@ -239,11 +239,11 @@ describe('conversation slot inject surface', () => {
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
const b = await bench()
const resident = b.residentSurface(ROOT)
const resident = b.residentApi(ROOT)
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
const { state, actions } = b.inputSurface(ROOT)
const { state, actions } = b.inputApi(ROOT)
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => {
@@ -261,7 +261,7 @@ describe('conversation slot inject surface', () => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me')
await b.runtime.dispose()
})
@@ -269,7 +269,7 @@ describe('conversation slot inject surface', () => {
const b = await bench()
// No-session resident (hero before any session): connect resolves and
// navigation proceeds without any draft choreography.
const noSession = b.residentSurface(undefined)
const noSession = b.residentApi(undefined)
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
void noSession.selectWorkspace('workspace-0' as never)
await vi.waitFor(() => {
@@ -279,15 +279,15 @@ describe('conversation slot inject surface', () => {
// Cross-session connect with an EMPTY draft: no move, no clearing.
const OTHER = 'b9-other' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
const resident = b.residentSurface(ROOT)
const { state } = b.inputSurface(ROOT)
const resident = b.residentApi(ROOT)
const { state } = b.inputApi(ROOT)
expect(state.getSnapshot().draft).toBe('')
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
void resident.selectWorkspace('workspace-3' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('')
// Connect failure: the rejection propagates to the caller (the view owns
// the rollback) and no further navigation happens.
@@ -310,7 +310,7 @@ describe('conversation slot inject surface', () => {
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const { injected } = b.conversationApi(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected.views.subscribe(listener)
@@ -332,7 +332,7 @@ describe('conversation slot inject surface', () => {
})
})
describe('details inject surface', () => {
describe('details inject API', () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const entry = b.entryOf('details')