transition.test.js 14.5 KB
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: new Date('2026-10-12T00:00:00.000Z'),
    updated_at: null,
    ...overrides,
  })
  return { task, guild, publisher_owner_user_id: 'u_pub', entry_count: 1 }
}

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', release: 'open', contest_close: 'reviewing' },
  submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed', release: 'open' },
  reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' },
  rework: { submit: 'submitted', dispute: 'disputed', release: 'open' },
  disputed: { resolve: 'settled', release: 'open' },
  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' || type === 'release') ? 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 })
    // 夹具的截止已过:按原工期(6 小时)从现在重新计时,并重排过期 job。
    expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released', 'job:open_expire'])
    expect(second.task.deadline_at).toEqual(new Date('2026-10-12T18:00:00.000Z'))
    expect(second.effects[0].reason).toBe('timeout')
    expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0)
  })

  it('截止前主动释放:截止不变、不重排过期 job', () => {
    const result = run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'release')
    expect(result.task).toMatchObject({ status: 'open', deadline_at: FUTURE })
    expect(effectTypes(result)).toEqual(['hold_end', 'task.released', 'task.released'])
  })

  it('未结算前持有人在任何状态都能放弃:返工次数清零、验收窗口清空;结算后不行', () => {
    for (const status of ['claimed', 'rework', 'submitted', 'disputed']) {
      const result = run(stateFor(status, 'piecework', { deadline_at: FUTURE, rework_count: status === 'claimed' ? 0 : 2 }), 'release')
      expect(result.task, status).toMatchObject({ status: 'open', holder_agent_id: null, rework_count: 0, review_due_at: null })
    }
    expect(effectTypes(run(stateFor('disputed', 'piecework', { deadline_at: FUTURE }), 'release'))).toEqual(['dispute_close', 'hold_end', 'task.released', 'task.released'])
    for (const status of ['settled', 'cancelled', 'refunded']) expect(run(stateFor(status), 'release')).toMatchObject({ ok: false, code: 'state_conflict' })
  })

  it('擂台赛退赛:还有别人时仍在比赛中,最后一人退出回到 open', () => {
    const lastOne = run(stateFor('in_contest'), 'release', entrant)
    expect(lastOne).toMatchObject({ ok: true, task: { status: 'open' }, effects: [{ type: 'entry_remove', agent_id: 'ag_entrant' }] })
    expect(run({ ...stateFor('in_contest'), entry_count: 3 }, 'release', entrant).task.status).toBe('in_contest')
    expect(run(stateFor('in_contest'), 'release', taker)).toMatchObject({ ok: false, code: 'forbidden' })
  })

  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, rarity: 3, 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('重复报名 → state_conflict', () => {
    expect(run(stateFor('in_contest'), 'enter', { ...entrant, agent_id: 'ag_entrant' })).toMatchObject({ ok: false, code: 'state_conflict' })
  })

  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/)
  })
})