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

243 lines
13 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 { parseWikiPages, parseWikiSource } from './extract'
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[] }
let cloneMissing = false
let registered: string[] = []
const probed = new Set<string>()
let sessionCwd = '/repo'
const web = new Map<string, 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', ((_$: unknown, e: { name: string }) => { registered.push(e.name); return { value: { name: e.name } } }) as never)
o('session.start', () => ({ cwd: '/repo' }))
o('session.cwd', (() => ({ value: sessionCwd })) as never)
mock.env(on, { HOME: '/home/u' })
o('fs.exists', ((_$: unknown, e: { path: string }) => (e.path.endsWith('/characters/index.json') && probed.add(e.path), { value: s.files.has(e.path) || (e.path === '/home/u/.cache/jsc-anima/repo/.git' && !cloneMissing) })) 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('process.run', ((_$: unknown, e: { argv: string[]; }) => {
s.cmds.push(e.argv)
const j = e.argv.join(' ')
if (e.argv[0] === 'curl') {
const body = web.get(e.argv[e.argv.length - 1]!)
return { value: { exitCode: body === undefined ? 22 : 0, stdout: body ?? '', stderr: '' } }
}
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' : j.startsWith('tea logins') ? 'admin https://gitea.jsc.idv.tw 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('/home/u/.cache/jsc-anima/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('/home/u/.cache/jsc-anima/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('/home/u/.cache/jsc-anima/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('/home/u/.cache/jsc-anima/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('/home/u/.cache/jsc-anima/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('/home/u/.cache/jsc-anima/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('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
expect(pack.timeline[0].chapter).toBe('第一章')
expect(pack.language).toBe('繁體中文,少量原作口癖')
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/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)
})
test('在非 jsc-anima 目錄執行:git 與 PR 都走 jsc-anima 的快取 clone,不碰 session 目錄', async ($, on) => {
sessionCwd = '/other/proj'
try {
const s = setup(on as never, [reply(char())], new Map([['/other/proj/ch1.txt', '第一段']]))
await $.session.start({ source: 'startup', cwd: '/other/proj' } as never)
await run($ as never, '/other/proj/ch1.txt')
await run($ as never, 'confirm')
expect(await run($ as never, 'confirm')).toContain('已建立 PR')
expect(s.cmds.some(c => c.includes('fetch'))).toBe(true)
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
expect(pr).toContain('jiantw83/jsc-anima')
expect(s.files.has('/other/proj/characters/index.json')).toBe(false)
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0].slug).toBe('aoi')
} finally {
sessionCwd = '/repo'
}
})
test('快取 clone 不存在時先 clone jsc-anima', async ($, on) => {
cloneMissing = true
try {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
const clone = s.cmds.find(c => c.includes('clone'))!
expect(clone).toContain('https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git')
} finally {
cloneMissing = false
}
})
test('paste:CRLF 換行也能分出章節名稱與內容', async ($, on) => {
const s = setup(on as never, [reply(char())])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'paste 第二章\r\n第一行\r\n第二行')
expect(s.prompts[0]).toContain('第一行')
})
test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {作品代號}-{角色代號} 並可啟動', async ($, on) => {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
registered = []
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
await run($ as never, 'confirm')
await run($ as never, 'confirm')
// 模擬 PR 合併、外掛更新:clone 內的 characters/ 內容出現在外掛目錄。
const root = ([...probed].find(q => !q.startsWith('/home/u/')) ?? '').replace('/characters/index.json', '')
for (const [k, v] of [...s.files]) if (k.startsWith('/home/u/.cache/jsc-anima/repo/characters/')) s.files.set(k.replace('/home/u/.cache/jsc-anima/repo', root), v)
await $.session.start({ source: 'startup', cwd: '/anywhere' } as never)
expect(registered).toContain('ts-aoi')
})
const WIKI = 'https://gitea.jsc.idv.tw/jiantw83/sao/wiki'
const listHtml = ['Home', '第10章', '第2章', '第1章', '_pages']
.map(n => `<a href="/jiantw83/sao/wiki/${encodeURIComponent(n)}">${n}</a>`)
.join('') + '<a href="/jiantw83/sao">repo</a><a href="/jiantw83/sao/wiki/第1章/_revision/abc">rev</a>'
test('parseWikiSource:從自然語言中取出 wiki URL,頁面清單與單頁分開判斷', () => {
const src = parseWikiSource(`請幫我依照順序逐章處理 ${WIKI}/?action=_pages 謝謝`)
expect(src).toMatchObject({ kind: 'list', base: WIKI })
expect(parseWikiSource(`處理 ${WIKI}/第2章。`)).toMatchObject({ kind: 'page', page: '第2章', base: WIKI })
expect(parseWikiSource('ch1.txt')).toBeUndefined()
expect(parseWikiSource('請處理 ch 1.txt')).toBeUndefined()
})
test('parseWikiPages:略過 Home/_pages/子路徑,並依章節數字自然排序', () => {
expect(parseWikiPages(listHtml, WIKI)).toEqual(['第1章', '第2章', '第10章'])
})
test('anima-extract 帶 URL 與自然語言:不當檔案路徑,依序逐章處理', async ($, on) => {
web.clear()
web.set(`${WIKI}/?action=_pages`, listHtml)
for (const n of ['第1章', '第2章', '第10章']) web.set(`${WIKI}/raw/${encodeURIComponent(n)}`, `${n}內容`)
const s = setup(on as never, [reply(char()), reply(char()), reply(char())])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const t1 = await run($ as never, `請幫我依照順序逐章處理 ${WIKI}/?action=_pages`)
expect(t1).not.toContain('找不到檔案')
expect(t1).toContain('共 3 章:第1章、第2章、第10章')
expect(t1).toContain('章節「第1章」')
expect(s.prompts[0]).toContain('第1章內容')
await run($ as never, 'confirm')
const t3 = await run($ as never, 'confirm')
expect(t3).toContain('已建立 PR')
expect(t3).toContain('剩餘 1 章')
expect(t3).toContain('章節「第2章」')
expect(s.prompts[1]).toContain('第2章內容')
await run($ as never, 'confirm')
const t5 = await run($ as never, 'confirm')
expect(t5).toContain('章節「第10章」')
await run($ as never, 'confirm')
const t7 = await run($ as never, 'confirm')
expect(t7).toContain('已建立 PR')
expect(t7).not.toContain('接著處理')
})
test('wiki 章節讀不到時回報原因與剩餘章數;cancel 清除佇列', async ($, on) => {
web.clear()
web.set(`${WIKI}/?action=_pages`, listHtml)
setup(on as never, [])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
expect(await run($ as never, `${WIKI}/?action=_pages`)).toContain('無法讀取 wiki 章節「第1章」')
expect(await run($ as never, 'cancel')).toContain('已取消')
})