// ack 契约面测试(dev-flow u-foundation 单元)。 // // 本单元只交付「依赖反转契约面」,不含 ack 业务逻辑,故测试只证明三件事: // 1. SchedulerBackend 扩面(registerProvider/unregisterProvider/getEntries)后可被纯对象 // 夹具实现(isIdle/getCurrentModel 是 PiSchedulerBackend 类方法、非接口成员); // 2. PiSchedulerBackend 对 pi / ctx 的转发语义(含缺字段兜底); // 3. ack 契约常量/判别值锚定(防后续单元依赖字符串时不发现改动)。 // // 不 mock pi:夹具是手写最小假对象;本套件零 FS,不触碰任何真实数据目录。 import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' import { describe, expect, it } from 'vitest' import { PiSchedulerBackend } from '../backend.js' import type { SchedulerBackend, SchedulerBackendCtx } from '../backend.js' import type { SchedulerEntryLike } from '../replay.js' import { ACK_CUSTOM_TYPE, ACK_CUSTOM_TYPE_PREFIX, type AckAvailability, type AckNotifyReason, type AckState, type SchedulerCurrentModel, type SchedulerProviderOverride, } from '../types.js' // ── 1. 扩面可被纯对象夹具实现(类型闭合)── /** * 纯对象夹具:只实现接口成员(sendMessage/appendEntry/getSessionFile/now + * registerProvider/unregisterProvider/getEntries)。 * `satisfies SchedulerBackend` 的**编译期**效力以「测试被 typecheck」为前提—— * `extensions/tsconfig.json` 的 exclude 覆盖全部测试目录,常态 `extensions:typecheck` * 不覆盖本文件(若将来把测试纳入 typecheck,这行才是编译期契约闭合证据);当前至少保证 * 运行期结构契约。 */ function createFixtureBackend(): SchedulerBackend { const entries: SchedulerEntryLike[] = [{ type: 'custom', customType: 'fixture', data: {} }] const providers = new Map() const model: SchedulerCurrentModel = { provider: 'prov', api: 'anthropic-messages', id: 'model-x' } return { async sendMessage() {}, appendEntry() {}, getSessionFile() { return '/fixture/session.json' }, now() { return 42 }, registerProvider(providerId, config) { providers.set(providerId, config) }, unregisterProvider(providerId) { providers.delete(providerId) }, getEntries() { return entries }, isIdle() { return true }, getCurrentModel() { return model }, } satisfies SchedulerBackend } describe('SchedulerBackend ack 扩面(纯对象夹具)', () => { it('夹具实现既有 + 新增全部方法且类型闭合', () => { const backend = createFixtureBackend() expect(backend.getEntries()).toHaveLength(1) expect(backend.isIdle()).toBe(true) expect(backend.getCurrentModel()).toEqual({ provider: 'prov', api: 'anthropic-messages', id: 'model-x' }) expect(backend.now()).toBe(42) expect(backend.getSessionFile()).toBe('/fixture/session.json') // 新面可调用(夹具不抛):覆写注册/注销是 ack 编排的基础面。 backend.registerProvider('prov', { api: 'openai-completions' }) backend.unregisterProvider('prov') }) }) // ── 2. PiSchedulerBackend 转发 / 归一 / 兜底 ── /** 最小假 pi 的记录面。 */ interface FakePi { pi: Pick< ExtensionAPI, 'sendMessage' | 'appendEntry' | 'registerProvider' | 'unregisterProvider' > providerCalls: [unknown, unknown][] unregisterCalls: unknown[] } /** * 最小假 pi(不 mock 框架):只实现 backend 转发所需的 4 面。 * registerProvider 用 `unknown` 形参 + 可选第二参以兼容 ExtensionAPI 的双重载 * ((provider: Provider) 与 (name: string, config: ProviderConfig))——两形参版本无法匹配 * 单参重载(TS:target signature provides too few arguments),实参仅做记录不做类型消费。 */ function createFakePi(): FakePi { const providerCalls: [unknown, unknown][] = [] const unregisterCalls: unknown[] = [] return { pi: { sendMessage: async () => {}, appendEntry: () => {}, registerProvider: (provider: unknown, config?: unknown) => { providerCalls.push([provider, config]) }, unregisterProvider: (providerId: unknown) => { unregisterCalls.push(providerId) }, }, providerCalls, unregisterCalls, } } describe('PiSchedulerBackend ack 扩面转发', () => { it('registerProvider 转发 pi 并零转换透传 (providerId, config)', () => { const { pi, providerCalls } = createFakePi() const ctx: SchedulerBackendCtx = { sessionManager: { getEntries: () => [], getSessionFile: () => '/s.json', }, } const backend = new PiSchedulerBackend(ctx, pi) const config: SchedulerProviderOverride = { api: 'anthropic-messages' } backend.registerProvider('prov', config) expect(providerCalls).toHaveLength(1) expect(providerCalls[0]).toEqual(['prov', config]) // 同一引用透传(无拷贝/重建),保证 streamSimple 等函数字段身份不变。 expect(providerCalls[0]![1]).toBe(config) }) it('unregisterProvider 转发 pi', () => { const { pi, unregisterCalls } = createFakePi() const ctx: SchedulerBackendCtx = { sessionManager: { getEntries: () => [], getSessionFile: () => '/s.json', }, } const backend = new PiSchedulerBackend(ctx, pi) backend.unregisterProvider('prov') expect(unregisterCalls).toEqual(['prov']) }) it('getEntries 对数组输入原样返回(不复制)', () => { const { pi } = createFakePi() const entries: SchedulerEntryLike[] = [{ type: 'custom', customType: 'a' }] const ctx: SchedulerBackendCtx = { sessionManager: { getEntries: () => entries, getSessionFile: () => '/s.json', }, } const backend = new PiSchedulerBackend(ctx, pi) expect(backend.getEntries()).toBe(entries) }) it('getSessionFile / isIdle / getCurrentModel 透传 ctx(实时读,非快照)', () => { const { pi } = createFakePi() const model: SchedulerCurrentModel = { provider: 'prov', api: 'openai-completions', id: 'm-1' } let idle = true const ctx: SchedulerBackendCtx = { sessionManager: { getEntries: () => [], getSessionFile: () => '/pass.json', }, isIdle: () => idle, model, } const backend = new PiSchedulerBackend(ctx, pi) expect(backend.getSessionFile()).toBe('/pass.json') expect(backend.isIdle()).toBe(true) // 每次调用都读 ctx 实时值(不是构造期快照)——ack 编排依赖的是调用时刻的空闲状态。 idle = false expect(backend.isIdle()).toBe(false) expect(backend.getCurrentModel()).toBe(model) }) it('ctx 缺 isIdle / model 时兜底:isIdle=false(fail-safe)、getCurrentModel=undefined', () => { const { pi } = createFakePi() const ctx: SchedulerBackendCtx = { sessionManager: { getEntries: () => [], getSessionFile: () => undefined, }, } const backend = new PiSchedulerBackend(ctx, pi) // 缺 isIdle:保守视作「有轮在跑」⇒ ack 不介入(确定性方向 = 不打扰)。 expect(backend.isIdle()).toBe(false) expect(backend.getCurrentModel()).toBeUndefined() expect(backend.getSessionFile()).toBeUndefined() }) }) // ── 3. ack 契约常量 / 类型锚定 ── describe('ack 契约常量与类型', () => { it('ACK_CUSTOM_TYPE / ACK_CUSTOM_TYPE_PREFIX 值锚定且前缀关系成立', () => { expect(ACK_CUSTOM_TYPE).toBe('pi-scheduler-ack:ack') expect(ACK_CUSTOM_TYPE_PREFIX).toBe('pi-scheduler-ack:') // 武装判别用前缀 startsWith、写入/精确匹配用全串——两者必须同源。 expect(ACK_CUSTOM_TYPE.startsWith(ACK_CUSTOM_TYPE_PREFIX)).toBe(true) }) it('AckAvailability / AckNotifyReason / AckState 判别值可构造', () => { const unavailable: AckAvailability[] = [ { available: false, reason: 'no-base' }, { available: false, reason: 'toggle-disabled' }, { available: false, reason: 'check-failed' }, ] expect(unavailable.map(a => (a.available ? 'available' : a.reason))).toEqual([ 'no-base', 'toggle-disabled', 'check-failed', ]) expect({ available: true }).toEqual({ available: true }) // 可通知形态只有两种:其余 E 形态(e1/e2/e4/e5/e6/e8b)不发用户通知——真实轮已 // 应答或与落盘无关,提示即反向撒谎(设计 §3.4);它们在日志面用字符串标签。 const notifyReasons: AckNotifyReason[] = ['e3-no-turn', 'e8-no-base'] expect(notifyReasons).toHaveLength(2) const state: AckState = { window: { providerId: 'anthropic' }, ackTurnStarted: false, ackStreamCalled: false, taskId: 't1', taskName: 'backup', ackText: 'saved', model: { provider: 'anthropic', api: 'anthropic-messages', id: 'claude-x' }, sessionFile: '/s.json', availability: { providerId: 'anthropic', isToggleDisabled: false, value: { available: true } }, } expect(state.window).toEqual({ providerId: 'anthropic' }) // 反向锚定:已删除的字段不得复活(定时器与 pending 均在 v6.3 移除)。 expect(Object.keys(state)).not.toContain('pending') expect(Object.keys(state)).not.toContain('writeCheckTimer') }) })