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

129 lines
7.4 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('已取消')
})
test('貼上章節內容:paste <章節名稱> 加下一行起的內容,並寫入語言欄位與 index.json', async ($, on) => {
const s = setup(on as never, [reply(char({ language: '繁體中文,少量原作口癖' }))])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
expect(await run($ as never, 'paste')).toContain('貼上章節的用法')
const t1 = await run($ as never, 'paste 第一章\n這是貼上的章節內容。')
expect(t1).toContain('aoi')
expect(s.prompts[0]).toContain('這是貼上的章節內容。')
await run($ as never, 'confirm')
await run($ as never, 'confirm')
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!)
expect(pack.timeline[0].chapter).toBe('第一章')
expect(pack.language).toBe('繁體中文,少量原作口癖')
expect(JSON.parse(s.files.get('/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' })
})
test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => {
const quoted = char({ persona: { selfRef: '我', address: '你', tone: '「這是一段很長很長的原作台詞內容,不應該出現在角色包裡面」', catchphrase: '加油', values: '誠實', taboos: '背叛' } })
const s = setup(on as never, [reply(quoted)], 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('疑似抄錄原作台詞')
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
})