Sessions could be archived from the sidebar row menu but never restored: the registry-global archive set was a one-way display filter with no inverse RPC and no surface listing its members. WorkspaceRegistry.unarchiveSession drops one id from archivedSessionIds in one durable write on the same operation chain as archive, with no session-existence probe because a removal cannot introduce an unknown referent. The Workspace controller exposes it as workspace.unarchiveSession returning the complete archive set, which the existing archived follow increment already propagates, so no frame type and no persisted field changes. The new client package dsh-client-ui-settings-unarchive-sessions owns the restore surface: one localized settings.section page listing the archive set joined with loaded session summaries, newest archive first, searchable by title or workspace, with one Unarchive action per row.
163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
import type {
|
|
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
|
|
} from '@deepseek-ai/dsh-api-workspace-controller/client'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
import { workspaceSnapshot } from './fixtures.ts'
|
|
import type { FixtureSnapshot, Stabilizer } from './fixtures.ts'
|
|
|
|
/** Writable test representation of the immutable Workspace Controller snapshot. */
|
|
type WorkspaceFixtureSnapshot = FixtureSnapshot<WorkspaceSnapshot>
|
|
|
|
/** Callable command names on the production Workspace Controller face. */
|
|
type WorkspaceAction = {
|
|
[Key in keyof IWorkspaces]: IWorkspaces[Key] extends (...args: never[]) => unknown ? Key : never
|
|
}[keyof IWorkspaces]
|
|
|
|
/** Test replacement retaining one Controller command's parameters and result. */
|
|
type WorkspaceStub<Key extends WorkspaceAction> = (
|
|
...args: Parameters<IWorkspaces[Key]>
|
|
) => ReturnType<IWorkspaces[Key]>
|
|
|
|
/**
|
|
* Workspaces test double. Implements the same IWorkspaces face features
|
|
* receive as `ctx.workspaces`, so a production face change breaks this
|
|
* double at compile time. Every action records into {@link
|
|
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
|
|
* richer behavior replace them via {@link TestWorkspaces.stub}.
|
|
*/
|
|
export class TestWorkspaces implements IWorkspaces {
|
|
/** The useWorkspaces standard feed. */
|
|
readonly list: SnapshotStore<WorkspaceFixtureSnapshot>
|
|
|
|
/** Calls observed on the action face, newest last. */
|
|
readonly calls: { method: string; args: unknown[] }[] = []
|
|
|
|
/** Replaceable action seat: feature tests may stub richer behavior. */
|
|
private readonly stubs = new Map<WorkspaceAction, (...args: unknown[]) => unknown>()
|
|
|
|
/**
|
|
* @param stabilize - the owning runtime's act wrapper.
|
|
*/
|
|
constructor(private readonly stabilize: Stabilizer) {
|
|
this.list = createSnapshotStore<WorkspaceFixtureSnapshot>({ ...workspaceSnapshot() })
|
|
}
|
|
|
|
/**
|
|
* Update the workspace list state through an immer draft.
|
|
* @param mutate - draft mutator.
|
|
*/
|
|
async update(mutate: (draft: WorkspaceFixtureSnapshot) => void): Promise<void> {
|
|
await this.stabilize(() => { this.list.update(mutate) })
|
|
}
|
|
|
|
/**
|
|
* Replace an action's behavior (the recorded call is still appended first).
|
|
* @param method - Controller action name (e.g. 'create').
|
|
* @param impl - replacement behavior.
|
|
*/
|
|
stub<Key extends WorkspaceAction>(method: Key, impl: WorkspaceStub<Key>): void {
|
|
this.stubs.set(method, impl as (...args: unknown[]) => unknown)
|
|
}
|
|
|
|
/**
|
|
* Create a Workspace (recorded). The default echoes a view derived from
|
|
* the input; stub for failure or list-coupled flows.
|
|
* @param input - the Host create payload.
|
|
* @returns the created Workspace view.
|
|
*/
|
|
async create(input: { path: string }): Promise<WorkspaceView> {
|
|
this.calls.push({ method: 'create', args: [input] })
|
|
const stub = this.stubs.get('create')
|
|
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
|
|
return {
|
|
workspaceId: `ws-${input.path}` as WorkspaceId,
|
|
title: input.path,
|
|
path: input.path,
|
|
sessionIds: [],
|
|
} as unknown as WorkspaceView
|
|
}
|
|
|
|
/**
|
|
* Rename a Workspace (recorded). The default echoes a minimal view.
|
|
* @param workspaceId - target workspace.
|
|
* @param title - new title.
|
|
* @returns the updated view.
|
|
*/
|
|
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
|
|
this.calls.push({ method: 'rename', args: [workspaceId, title] })
|
|
const stub = this.stubs.get('rename')
|
|
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
|
|
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
|
|
}
|
|
|
|
/**
|
|
* Delete a Workspace (recorded; default no-op).
|
|
* @param workspaceId - target workspace.
|
|
*/
|
|
async delete(workspaceId: WorkspaceId): Promise<void> {
|
|
this.calls.push({ method: 'delete', args: [workspaceId] })
|
|
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
|
|
}
|
|
|
|
/**
|
|
* Move a Workspace in display order (recorded; default no-op).
|
|
* @param workspaceId - Workspace to move.
|
|
* @param beforeWorkspaceId - Anchor; omitted appends.
|
|
*/
|
|
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
|
|
this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] })
|
|
await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise<void> | undefined)
|
|
}
|
|
|
|
/**
|
|
* Move an accounted session (recorded). The default echoes a minimal view.
|
|
* @param workspaceId - target workspace.
|
|
* @param sessionId - session to move.
|
|
* @param beforeSessionId - anchor; omitted appends.
|
|
* @returns the updated view.
|
|
*/
|
|
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
|
|
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
|
|
const stub = this.stubs.get('insertSessionBefore')
|
|
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
|
|
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
|
|
}
|
|
|
|
/**
|
|
* Archive a session (recorded). The default mirrors the production face's
|
|
* observable effect: the id joins the list state's archive set.
|
|
* @param sessionId - session to archive.
|
|
*/
|
|
async archiveSession(sessionId: SessionId): Promise<void> {
|
|
this.calls.push({ method: 'archiveSession', args: [sessionId] })
|
|
const stub = this.stubs.get('archiveSession')
|
|
if (stub !== undefined) {
|
|
await (stub(sessionId) as Promise<void>)
|
|
return
|
|
}
|
|
await this.update((draft) => {
|
|
draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId]
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Unarchive a session (recorded). The default mirrors the production face's
|
|
* observable effect: the id leaves the list state's archive set.
|
|
* @param sessionId - session to unarchive.
|
|
*/
|
|
async unarchiveSession(sessionId: SessionId): Promise<void> {
|
|
this.calls.push({ method: 'unarchiveSession', args: [sessionId] })
|
|
const stub = this.stubs.get('unarchiveSession')
|
|
if (stub !== undefined) {
|
|
await (stub(sessionId) as Promise<void>)
|
|
return
|
|
}
|
|
await this.update((draft) => {
|
|
draft.archivedSessionIds = draft.archivedSessionIds.filter(id => id !== sessionId)
|
|
})
|
|
}
|
|
}
|