import { describe, expect, it } from 'vitest' import { taskFromRow, TASK_STATUSES } from '../src/task.js' import { publishStatus, TASK_ACTIONS, transition } from '../src/transition.js' const NOW = new Date('2026-10-12T12:00:00.000Z') const PAST = new Date('2026-10-12T06:00:00.000Z') const FUTURE = new Date('2026-10-13T12:00:00.000Z') const guild = { checkpoint_interval_sec: 21600, piece_review_window_sec: 86400, contest_review_window_sec: 259200 } const publisher = agent('ag_pub', 'u_pub') const holder = agent('ag_holder', 'u_holder') const taker = agent('ag_taker', 'u_taker') const entrant = { ...agent('ag_entrant', 'u_entrant'), is_entrant: true } const lead = { kind: 'lead', user_id: 'u_lead' } const operator = { kind: 'operator', user_id: 'u_ops' } const worker = { kind: 'worker' } function agent(agent_id, owner_user_id) { return { kind: 'agent', agent_id, owner_user_id, max_rarity: 10, active_hold_count: 0, is_entrant: false } } /** 每个状态一条符合生产 schema 的任务行(到期类时间都已过,便于 worker 动作成立)。 */ function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrides = {}) { const held = ['claimed', 'submitted', 'rework'].includes(status) || (status === 'disputed' && template === 'piecework') const task = taskFromRow({ id: 't_1', guild_id: 'g_1', publisher_agent_id: 'ag_pub', template, title: 'Translate onboarding email', spec: 'Translate to Japanese', reward: 100, rarity: 2, deadline_at: PAST, status, client_request_key: 'req_1', holder_agent_id: held ? 'ag_holder' : null, next_checkpoint_due_at: status === 'claimed' ? PAST : null, missed_checkpoints: 0, rework_count: status === 'rework' ? 1 : 0, review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null, settled_at: status === 'settled' ? PAST : null, created_at: PAST, updated_at: null, ...overrides, }) return { task, guild, publisher_owner_user_id: 'u_pub' } } const TEMPLATE_OF = { in_contest: 'contest', reviewing: 'contest' } /** 每个动作的合法发起者与参数。 */ const ACTOR_OF = { approve: lead, reject_approval: lead, claim: taker, enter: taker, cancel: publisher, open_expire: worker, edit: publisher, checkpoint: holder, checkpoint_miss: worker, release: holder, hold_timeout: worker, transfer_accept: taker, submit: holder, accept: publisher, reject: publisher, review_auto_accept: worker, contest_close: worker, select_winner: publisher, dispute: publisher, resolve: operator, } const PARAMS_OF = { edit: { add_reward: 50, deadline_at: FUTURE }, review_auto_accept: { winner_agent_id: 'ag_entrant' }, select_winner: { winner_agent_id: 'ag_entrant' }, resolve: { outcome: 'settled', winner_agent_id: 'ag_holder' }, } /** SDD 状态转移表:status → action → 期望的新状态。表里没有的组合一律 state_conflict。 */ const EXPECTED = { pending_approval: { approve: 'open', reject_approval: 'cancelled' }, open: { claim: 'claimed', enter: 'in_contest', cancel: 'cancelled', open_expire: 'cancelled', edit: 'open' }, claimed: { checkpoint: 'claimed', checkpoint_miss: 'claimed', release: 'open', hold_timeout: 'open', transfer_accept: 'claimed', submit: 'submitted' }, in_contest: { enter: 'in_contest', submit: 'in_contest', contest_close: 'reviewing' }, submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed' }, reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' }, rework: { submit: 'submitted', dispute: 'disputed' }, disputed: { resolve: 'settled' }, settled: {}, cancelled: {}, refunded: {}, } function run(state, type, actor = ACTOR_OF[type], params = PARAMS_OF[type]) { return transition(state, { type, ...params }, actor, NOW) } const effectTypes = (result) => result.effects.map((effect) => effect.type === 'event' ? effect.event_type : effect.type === 'job' ? `job:${effect.kind}` : effect.type) describe('SM-01 transition():11 个状态 × 全部动作', () => { it('期望表覆盖全部状态', () => { expect(Object.keys(EXPECTED)).toEqual([...TASK_STATUSES]) }) for (const status of TASK_STATUSES) { for (const type of TASK_ACTIONS) { const expected = EXPECTED[status][type] it(`${status} × ${type} → ${expected ?? 'state_conflict'}`, () => { const actor = status === 'in_contest' && type === 'submit' ? entrant : ACTOR_OF[type] // open 的 enter 只对擂台赛成立;计件活 enter 的规则错误在下面单独断言。 const template = status === 'open' && type === 'enter' ? 'contest' : undefined const result = run(stateFor(status, template), type, actor) if (expected === undefined) { expect(result).toMatchObject({ ok: false, code: 'state_conflict' }) return } expect(result.ok).toBe(true) expect(result.task.status).toBe(expected) // 新任务必须仍满足构造点的不变量。 expect(() => taskFromRow(result.task)).not.toThrow() }) } } it('open 擂台赛:enter → in_contest,claim 是规则错误', () => { const contest = stateFor('open', 'contest') expect(run(contest, 'enter')).toMatchObject({ ok: true, task: { status: 'in_contest' }, effects: [{ type: 'entry_add', agent_id: 'ag_taker' }] }) expect(run(contest, 'claim')).toMatchObject({ ok: false, code: 'rule_violation' }) expect(run(stateFor('open'), 'enter')).toMatchObject({ ok: false, code: 'rule_violation' }) }) it('错误的发起者一律 forbidden', () => { for (const status of TASK_STATUSES) { for (const type of Object.keys(EXPECTED[status])) { if (type === 'claim' || type === 'enter' || type === 'transfer_accept') continue const stranger = ACTOR_OF[type].kind === 'agent' ? worker : agent('ag_stranger', 'u_stranger') expect(run(stateFor(status), type, stranger), `${status} × ${type}`).toMatchObject({ ok: false, code: 'forbidden' }) } } }) }) describe('SM-01 副作用与平台规则', () => { it('认领:开始持有、通知发布方、排检查点与超时 job', () => { const result = run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'claim') expect(effectTypes(result)).toEqual(['hold_start', 'task.claimed', 'job:checkpoint_warn', 'job:checkpoint_miss', 'job:hold_timeout']) expect(result.task.holder_agent_id).toBe('ag_taker') expect(result.task.next_checkpoint_due_at).toEqual(new Date('2026-10-12T18:00:00.000Z')) expect(result.effects[2].run_at).toEqual(new Date('2026-10-12T17:00:00.000Z')) }) it('自接自单、同主人、并发上限 → state_conflict;稀有度超等级 → rule_violation', () => { const open = stateFor('open') expect(run(open, 'claim', publisher)).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(open, 'claim', agent('ag_sibling', 'u_pub'))).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(open, 'claim', { ...taker, active_hold_count: 3 })).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(open, 'claim', { ...taker, active_hold_count: 2 }).ok).toBe(true) expect(run(open, 'claim', { ...taker, max_rarity: 1 })).toMatchObject({ ok: false, code: 'rule_violation' }) expect(run(stateFor('open', 'contest'), 'enter', agent('ag_sibling', 'u_pub'))).toMatchObject({ ok: false, code: 'state_conflict' }) }) it('检查点缺一次只告警并顺延,缺两次释放回 open', () => { const first = run(stateFor('claimed'), 'checkpoint_miss') expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') }) const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss') expect(second.task).toMatchObject({ status: 'open', holder_agent_id: null, missed_checkpoints: 0 }) expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released']) expect(second.effects[0].reason).toBe('timeout') expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0) }) it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(stateFor('claimed', 'piecework', { next_checkpoint_due_at: FUTURE }), 'checkpoint_miss')).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(stateFor('in_contest', 'contest', { deadline_at: FUTURE }), 'contest_close')).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(stateFor('submitted', 'piecework', { review_due_at: FUTURE }), 'review_auto_accept')).toMatchObject({ ok: false, code: 'state_conflict' }) }) it('提交开启验收窗口:计件活 24h,擂台赛关闭后 72h', () => { const submitted = run(stateFor('claimed'), 'submit') expect(submitted.task.review_due_at).toEqual(new Date('2026-10-13T12:00:00.000Z')) expect(effectTypes(submitted)).toEqual(['hold_end', 'task.submitted', 'job:review_warn', 'job:review_auto_accept']) expect(run(stateFor('in_contest'), 'contest_close').task.review_due_at).toEqual(new Date('2026-10-15T12:00:00.000Z')) }) it('驳回两次进返工,第 3 次进仲裁', () => { expect(run(stateFor('submitted'), 'reject').task).toMatchObject({ status: 'rework', rework_count: 1, review_due_at: null }) expect(run(stateFor('submitted', 'piecework', { rework_count: 1 }), 'reject').task).toMatchObject({ status: 'rework', rework_count: 2 }) const third = run(stateFor('submitted', 'piecework', { rework_count: 2 }), 'reject') expect(third.task).toMatchObject({ status: 'disputed', rework_count: 2 }) expect(effectTypes(third)).toEqual(['dispute_open', 'job:dispute_arbitrate']) }) it('结算与退款的托管动作', () => { expect(run(stateFor('submitted'), 'accept').effects[0]).toEqual({ type: 'escrow_settle', winner_agent_id: 'ag_holder' }) expect(run(stateFor('reviewing'), 'select_winner').effects[0]).toEqual({ type: 'escrow_settle', winner_agent_id: 'ag_entrant' }) const noWinner = run(stateFor('reviewing'), 'review_auto_accept', worker, { winner_agent_id: null }) expect(noWinner.task.status).toBe('refunded') expect(noWinner.effects[0]).toEqual({ type: 'escrow_refund' }) expect(run(stateFor('disputed'), 'resolve', operator, { outcome: 'refunded', winner_agent_id: null }).task.status).toBe('refunded') for (const type of ['cancel', 'open_expire']) expect(run(stateFor('open'), type).effects).toEqual([{ type: 'escrow_refund' }]) }) it('修改只能加赏金或延长截止', () => { const edited = run(stateFor('open'), 'edit') expect(edited.task).toMatchObject({ reward: 150, deadline_at: FUTURE }) expect(effectTypes(edited)).toEqual(['escrow_topup', 'job:open_expire']) expect(run(stateFor('open'), 'edit', publisher, { add_reward: 0, deadline_at: null })).toMatchObject({ ok: false, code: 'rule_violation' }) expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'edit', publisher, { add_reward: 0, deadline_at: PAST })).toMatchObject({ ok: false, code: 'rule_violation' }) }) it('争议只能由发布方或接单方发起', () => { expect(run(stateFor('submitted'), 'dispute', holder).task.status).toBe('disputed') expect(run(stateFor('reviewing'), 'dispute', entrant).task.status).toBe('disputed') expect(run(stateFor('submitted'), 'dispute', taker)).toMatchObject({ ok: false, code: 'forbidden' }) }) it('发布时的初始状态', () => { expect(publishStatus({ require_lead_approval: true, publisher_role: 'company' })).toBe('pending_approval') expect(publishStatus({ require_lead_approval: false, publisher_role: 'company' })).toBe('open') expect(publishStatus({ require_lead_approval: true, publisher_role: 'member' })).toBe('open') }) it('未知动作与非法行直接 throw', () => { expect(() => transition(stateFor('open'), { type: 'teleport' }, worker, NOW)).toThrow(/Unhandled action: teleport, id: t_1/) expect(() => stateFor('archived')).toThrow(/Unhandled status: archived, id: t_1/) expect(() => stateFor('claimed', 'piecework', { holder_agent_id: null })).toThrow(/holder_agent_id missing/) }) })