import { expect, mock, test } from 'claude-code/testing' import { parseWikiPages, parseWikiSource } from './extract' import type { Extracted } from './extract' const char = (over: Record = {}, 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; cmds: string[][]; prompts: string[] } let cloneMissing = false let registered: string[] = [] const probed = new Set() let sessionCwd = '/repo' const web = new Map() const setup = (on: never, answers: string[], files = new Map()): 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 => `${n}`) .join('') + 'reporev' 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('已取消') })