transition.test.js
12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
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/)
})
})