Files
jsc-anima/hooks/extract.test.ts
T

105 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, mock, test } from 'claude-code/testing'
import type { Extracted } from './extract'
const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] =>
({
name: '小葵', nameEn: 'Aoi', slug: 'aoi',
persona: { selfRef: '我', address: '你', tone, catchphrase: '加油', values: '誠實', taboos: '背叛' },
axes: { gentle: 7, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 },
anime: { moe: '認真', speech: '柔和', emotions: '會臉紅', kaomoji: '(^_^)', themeColor: '#88c' },
state: { mood: '緊張', dialogueStyle: '客氣', monologue: '想變強', relationships: [{ with: '阿零', kind: 'friend', note: '同伴' }], age: 16, place: '學院' },
...over,
}) as never
const reply = (c: Extracted['characters'][number]) =>
JSON.stringify({ work: { name: '測試物語', nameEn: 'Test Story', slug: 'ts', timeline: [{ time: '春', place: '學院' }], terms: [{ term: '魔核', definition: '力量來源' }] }, characters: [c] })
type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] }
const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => {
const s: Setup = { files, cmds: [], prompts: [] }
const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void
mock.store(on)
o('command.register', () => ({ value: { name: 'x' } }))
o('session.start', () => ({ cwd: '/repo' }))
o('session.cwd', (() => ({ value: '/repo' })) as never)
o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never)
o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never)
o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never)
o('model.complete', ((_$: unknown, e: { prompt: string }) => {
s.prompts.push(e.prompt)
return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } }
}) as never)
o('env.get', (() => ({ value: undefined })) as never)
o('process.run', ((_$: unknown, e: { argv: string[] }) => {
s.cmds.push(e.argv)
const j = e.argv.join(' ')
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.example/o/r.git' : j.startsWith('tea logins') ? 'admin https://gitea.example u' : j.startsWith('tea pulls') ? 'PR#1' : ''
return { value: { exitCode: 0, stdout: out, stderr: '' } }
}) as never)
return s
}
const run = ($: never, args: string) => ($ as any).command.run({ command: 'anima-extract', args }).then((r: { text: string }) => r.text)
test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR', async ($, on) => {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', '第一段\n\n第二段']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const t1 = await run($ as never, 'ch1.txt')
expect(t1).toContain('ts')
expect(t1).toContain('aoi')
expect(t1).toContain('尚未建立')
expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false)
const t2 = await run($ as never, 'confirm work=testy aoi=ao')
expect(t2).toContain('變更摘要')
expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false)
const t3 = await run($ as never, 'confirm')
expect(t3).toContain('已建立 PR')
const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!)
expect(pack.timeline[0].chapter).toBe('ch1')
expect(pack.timeline[0].age).toBe(16)
expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核')
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
expect(pr).toContain('main')
expect(pr).toContain('feat/extract-testy-ch1')
expect(s.cmds.some(c => c[0] === 'git' && c[1] === 'commit' && c[3]!.startsWith('feat: '))).toBe(true)
})
test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確認', async ($, on) => {
const files = new Map([['/repo/ch1.txt', 'a'], ['/repo/ch2.txt', 'b']])
const s = setup(on as never, [reply(char()), reply(char()), reply(char({ state: { mood: '開心', dialogueStyle: '', monologue: '', relationships: [], age: 17, place: '' } }, '傲嬌'))], files)
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const write = async (f: string) => {
await run($ as never, f)
await run($ as never, 'confirm')
return run($ as never, 'confirm')
}
await write('ch1.txt')
await write('ch1.txt')
expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1)
await run($ as never, 'ch2.txt')
const summary = await run($ as never, 'confirm')
expect(summary).toContain('追加')
expect(summary).toContain('待確認')
await run($ as never, 'confirm')
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!)
expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2'])
expect(pack.persona.tone).toBe('溫柔')
expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' })
})
test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async ($, on) => {
const bad = char({ axes: { gentle: 99, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 } })
const s = setup(on as never, [reply(bad)], new Map([['/repo/ch1.txt', 'a']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
expect(await run($ as never, 'confirm')).toContain('schema 驗證失敗')
expect(s.cmds.some(c => c[0] === 'tea')).toBe(false)
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
expect(await run($ as never, 'cancel')).toContain('已取消')
})