acmer-coach 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ function shape(schema, value) {
8
+ if (schema.type === 'object') {
9
+ assert.ok(value && typeof value === 'object' && !Array.isArray(value))
10
+ if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected field ${key}`)
11
+ for (const [key, field] of Object.entries(schema.properties ?? {})) {
12
+ if (field.required) assert.ok(key in value, `missing field ${key}`)
13
+ if (key in value) shape(field, value[key])
14
+ }
15
+ } else if (schema.type === 'array') {
16
+ assert.ok(Array.isArray(value))
17
+ if (schema.items) value.forEach(item => shape(schema.items, item))
18
+ } else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
19
+ else assert.equal(typeof value, schema.type)
20
+ }
21
+
22
+ test('registered coaching tools expose cited findings, sourced teaching and complete refusal shapes', async () => {
23
+ const dir = mkdtempSync(join(tmpdir(), 'coach-contract-'))
24
+ const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
25
+ try {
26
+ writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes: []\n')
27
+ process.env.COACH_DATA_DIR = dir
28
+ process.env.COACH_DB = join(dir, 'unused.db')
29
+ const { apply } = await import('../index.js?coaching-contract')
30
+ const tools = new Map()
31
+ apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
32
+ const call = async (name, args) => {
33
+ const tool = tools.get(name)
34
+ const result = await tool.execute(args)
35
+ shape(tool.output.schema, result)
36
+ return result
37
+ }
38
+ assert.ok(tools.get('coach_postmortem').parameters.properties.findings)
39
+ assert.ok(tools.get('coach_focus').output.schema.properties.profile.properties?.id, 'SDK needs explicit nested profile fields')
40
+ assert.ok(tools.get('coach_vp_import').parameters.properties.events.items.properties.measurements)
41
+ assert.ok((await call('coach_target', { action: 'profiles' })).availableProfiles.length)
42
+ const missing = await call('coach_postmortem', { eventId: 'unknown', findings: [] })
43
+ assert.equal(missing.ok, false)
44
+ const target = await call('coach_target', { action: 'set', expectedRevision: 0, contest: '样本训练', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
45
+ priorities: [{ id: 'graph-modeling', weight: 10 }] })
46
+ assert.equal(target.ok, true, target.reject)
47
+ await call('coach_vp_import', { events: [{ eventId: 'vp-contract', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'WA' }] }] })
48
+ const facts = await call('coach_curriculum', { action: 'assess' })
49
+ const fact = facts.evidence.find(entry => entry.source === 'vp')
50
+ assert.ok(fact)
51
+ const saved = await call('coach_postmortem', { eventId: 'vp-contract', findings: [{ competency: 'graph-modeling', status: 'confirmed',
52
+ cause: '对照题面和代码确认建图方向写反了', evidenceRefs: [fact.id] }] })
53
+ assert.equal(saved.ok, true, saved.reject)
54
+ const focus = await call('coach_focus', {})
55
+ assert.equal(focus.focus[0].gapCount, 1)
56
+ assert.match(focus.text, /已确认|确认短板/)
57
+ const measurementImport = await call('coach_vp_import', { events: [{ eventId: 'measured-contract', contest: 'VP', date: '2026-10-10',
58
+ problems: [{ problemId: 'A', status: 'AC' }], measurements: [{ competency: 'contest-reading', metric: 'missed-reading', value: 0, passed: true, independent: true }] }] })
59
+ assert.equal(measurementImport.ok, true, measurementImport.reject)
60
+ const measured = await call('coach_curriculum', { action: 'assess', evidenceEventIds: ['measured-contract'] })
61
+ assert.equal(measured.evidence.find(entry => entry.metric === 'missed-reading')?.value, 0)
62
+ const gap = (await call('coach_curriculum', { action: 'assess', evidenceEventIds: ['vp-contract'] })).evidence.find(entry => entry.signal === 'gap')
63
+ const created = await call('coach_curriculum', { action: 'create', expectedRevision: 0, goal: '区域赛稳定建图', reason: '针对已经确认的建图错误,用两场零错误指标验收', phases: [{
64
+ id: 'graph-measured', title: '稳定建图', kind: 'training', finding: 'gap', nodes: [], competencies: ['graph-modeling'],
65
+ outcome: '两场不同的VP都能独立正确建模', exitCriteria: '两场独立验收无建模错误',
66
+ basis: '对照题面和代码已确认建图方向错误', goalContribution: '减少区域赛建模题错误提交', priorityReason: '现有事实显示建模方向错误需要先修', estimatedHours: 5,
67
+ evidenceRefs: [gap.id], uncertainty: '', diagnosticMethod: '', exitChecks: [{ competency: 'graph-modeling', minEvents: 2, independent: true, metric: 'model-errors', maxValue: 0 }],
68
+ }] })
69
+ assert.equal(created.ok, true, created.reject)
70
+ const now = new Date()
71
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
72
+ for (let i = 0; i < 2; i++) {
73
+ assert.equal((await call('coach_vp_import', { events: [{ eventId: `metric-exit-${i}`, contest: 'VP', date, problems: [{ problemId: 'B', status: 'AC' }],
74
+ measurements: [{ competency: 'graph-modeling', metric: 'model-errors', value: 0, passed: true, independent: true, occurredAt: new Date().toISOString() }] }] })).ok, true)
75
+ const state = await call('coach_curriculum', { action: 'read' })
76
+ assert.equal(state.exitCheckResults[0].matchedEvents, i + 1)
77
+ if (i === 0) assert.equal((await call('coach_curriculum', { action: 'advance', expectedRevision: state.revision, reason: '只有一场,不能通过两场的验收条件' })).ok, false)
78
+ }
79
+ const finished = await call('coach_curriculum', { action: 'advance', expectedRevision: created.revision, reason: '两场不同的新VP都有独立且已验收的零建模错误测量' })
80
+ assert.equal(finished.status, 'complete', finished.reject)
81
+ const scope = await call('coach_scope', { id: '最短路' })
82
+ assert.equal(scope.id, '最短路')
83
+ assert.ok(scope.sources.length)
84
+ assert.ok(scope.recognition.length)
85
+ await call('coach_scope', { id: '不存在的卡片' })
86
+ } finally {
87
+ for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
88
+ if (value === undefined) delete process.env[key]
89
+ else process.env[key] = value
90
+ }
91
+ rmSync(dir, { recursive: true, force: true })
92
+ }
93
+ })
@@ -24,6 +24,7 @@ const phases = [
24
24
  { id: 'foundation', title: '稳定基础能力', outcome: '独立建模与实现', nodes: ['B', 'C'], exitCriteria: '独立变式与延迟复测', requiredVerified: ['B', 'C'] },
25
25
  { id: 'transfer', title: '组合迁移', outcome: '识别组合题机制', nodes: ['D'], exitCriteria: '无标签混合题独立完成', requiredVerified: ['D'] },
26
26
  ].map((phase) => ({ ...phase, kind: 'training', finding: 'gap',
27
+ exitChecks: phase.nodes.map((node) => ({ node, minEvents: 1 })),
27
28
  basis: '已存在对应知识点的未通过检测记录,需要针对性训练',
28
29
  goalContribution: '通过独立建模和稳定实现提升区域赛可完成题数',
29
30
  priorityReason: '先修复已观测的基础问题,再进入有依赖的组合训练',
@@ -38,13 +39,14 @@ function prove(progress, node) {
38
39
  function fixture() {
39
40
  const dataDir = mkdtempSync(join(testBase, 'coach-curriculum-'))
40
41
  const progress = { nodes: structuredClone(seedNodes), pendingActions: {} }
42
+ const clock = { at: '2026-10-09T12:00:00+08:00' }
41
43
  const options = { dataDir, loadMap: () => ({ byId }), loadProgress: () => progress,
42
- now: () => new Date('2026-10-09T12:00:00+08:00') }
44
+ now: () => new Date(clock.at) }
43
45
  const store = createCurriculumStore(options)
44
46
  const create = (extra = {}) => store.execute({ action: 'create', expectedRevision: 0,
45
47
  goal: '区域赛稳定表现,长期向 WF 发展', baseline: '水平与队伍缺口已核实,尚需独立检测',
46
48
  weeklyHours: 30, phases, reason: '根据用户目标与真实训练表现建立课程路线', ...extra })
47
- return { dataDir, progress, options, store, create }
49
+ return { dataDir, progress, options, clock, store, create }
48
50
  }
49
51
  function validateShape(schema, value) {
50
52
  if (schema.type === 'object') {
@@ -61,21 +63,203 @@ function validateShape(schema, value) {
61
63
  }
62
64
 
63
65
  const phaseFor = (nodes, extra = {}) => ({ ...phases[0], nodes, requiredVerified: [...nodes],
66
+ exitChecks: nodes.map((node) => ({ node, minEvents: 1 })),
64
67
  evidenceRefs: seedEvidence.filter((entry) => nodes.includes(entry.node)).map((entry) => entry.id), ...extra })
65
68
  const diagnostic = (nodes = ['B']) => phaseFor(nodes, {
66
69
  id: 'diagnostic', title: '核实当前能力缺口', kind: 'diagnostic', finding: 'unknown',
67
70
  basis: '尚无足够的实际结果证明具体短板,先进行诊断',
68
71
  uncertainty: '尚未确认是否独立建模、实现稳定性或熟练度存在缺口',
69
72
  diagnosticMethod: '独立完成小规模变式,记录结果并对失败代码进行判因',
70
- evidenceRefs: [], requiredVerified: [], estimatedHours: 2,
73
+ evidenceRefs: [], requiredVerified: [], exitChecks: [], estimatedHours: 2,
71
74
  })
72
75
  const provisional = (nodes = ['D']) => phaseFor(nodes, {
73
76
  id: 'future', kind: 'provisional', finding: 'unknown', basis: '该方向目前仅是与目标相关的远期假设,尚待诊断',
74
77
  uncertainty: '尚未取得这些节点的训练结果,进入前须重新核实依据',
75
- evidenceRefs: [], requiredVerified: [], estimatedHours: 0,
78
+ evidenceRefs: [], requiredVerified: [], exitChecks: [], estimatedHours: 0,
76
79
  })
77
80
  const advance = (f, revision = f.store.snapshot().revision) => f.store.execute({ action: 'advance', expectedRevision: revision, reason: '依据本阶段实际产生的检测与训练记录评估成果' })
78
81
 
82
+ test('new training stages reject narrative-only exit criteria', () => {
83
+ const f = fixture()
84
+ const phase = phaseFor(['B'])
85
+ delete phase.exitChecks
86
+ const result = f.create({ phases: [phase] })
87
+ assert.equal(result.ok, false)
88
+ assert.match(result.reject, /exitChecks/)
89
+ assert.equal(f.store.load(), null)
90
+ })
91
+
92
+ test('structured exits count distinct fresh events and delayed independent performance', () => {
93
+ const f = fixture()
94
+ const phase = phaseFor(['B'], { exitChecks: [{ node: 'B', minEvents: 2, minDistinctDays: 2, independent: true, maxMinutes: 45 }] })
95
+ assert.equal(f.create({ phases: [phase] }).ok, true)
96
+ f.progress.nodes.B.status = 'verified'
97
+ f.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P1', solved: true, independent: true, minutes: 30 }]
98
+ let state = f.store.execute({ action: 'read' })
99
+ assert.equal(advance(f).ok, false)
100
+ assert.equal(state.exitCheckResults[0].matchedEvents, 1)
101
+ f.progress.nodes.B.passes.push({ date: '2026-10-09', problemId: 'P2', solved: true, independent: true, minutes: 30 })
102
+ state = f.store.execute({ action: 'read' })
103
+ assert.equal(state.exitCheckResults[0].matchedEvents, 2)
104
+ assert.equal(state.exitCheckResults[0].distinctDays, 1)
105
+ assert.equal(advance(f).ok, false)
106
+ f.clock.at = '2026-10-10T12:00:00+08:00'
107
+ f.progress.nodes.B.passes.push({ date: '2026-10-10', problemId: 'P3', solved: true, independent: true, minutes: 60 })
108
+ assert.equal(advance(f).ok, false)
109
+ f.progress.nodes.B.passes.push({ date: '2026-10-10', problemId: 'P4', solved: true, independent: true, minutes: 35 })
110
+ assert.equal(advance(f).status, 'complete')
111
+ })
112
+
113
+ test('structured exits reject missing facts, repeated events and edits to old successes', () => {
114
+ const f = fixture()
115
+ f.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P-old', solved: true, independent: true, minutes: 90 }]
116
+ const phase = phaseFor(['B'], { exitChecks: [{ node: 'B', minEvents: 2, independent: true, maxMinutes: 45 }] })
117
+ assert.equal(f.create({ phases: [phase] }).ok, true)
118
+ f.progress.nodes.B.status = 'verified'
119
+ f.progress.nodes.B.passes[0].minutes = 30
120
+ prove(f.progress, 'B') // A passed check without an explicit independence fact cannot satisfy this rule.
121
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 0)
122
+ const fresh = { date: '2026-10-09', problemId: 'P-new', solved: true, independent: true, minutes: 25 }
123
+ f.progress.nodes.B.passes.push(fresh, structuredClone(fresh))
124
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 1)
125
+ assert.equal(advance(f).ok, false)
126
+ f.progress.nodes.B.passes.push({ ...fresh, problemId: 'P-another' })
127
+ assert.equal(advance(f).status, 'complete')
128
+ })
129
+
130
+ test('changing exit requirements resets the entry snapshot rather than reusing current success', () => {
131
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
132
+ prove(f.progress, 'B')
133
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, [])
134
+ const phase = phaseFor(['B'], { exitChecks: [{ node: 'B', minEvents: 2 }] })
135
+ const result = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [phase], reason: '原单次检测不足以证明稳定性,追加两次新检测条件' })
136
+ assert.equal(result.ok, true, result.reject)
137
+ assert.equal(result.exitCheckResults[0].matchedEvents, 0)
138
+ prove(f.progress, 'B'); assert.equal(advance(f).ok, false)
139
+ prove(f.progress, 'B'); assert.equal(advance(f).status, 'complete')
140
+ })
141
+
142
+ test('v2 courses remain unchanged on read and completed v2 phases survive explicit v3 repair', () => {
143
+ const f = fixture(); assert.equal(f.create().ok, true)
144
+ prove(f.progress, 'B'); prove(f.progress, 'C')
145
+ const old = parse(readFileSync(f.store.path, 'utf8'))
146
+ old.version = 2; old.current = 1; old.focusNodes = []
147
+ old.phases.forEach((phase) => { delete phase.exitChecks })
148
+ const bytes = stringify(old); writeFileSync(f.store.path, bytes)
149
+ const result = f.store.execute({ action: 'read' })
150
+ assert.equal(result.ok, true); assert.equal(result.needsReview, true)
151
+ assert.equal(readFileSync(f.store.path, 'utf8'), bytes)
152
+ assert.equal(f.store.assignment({ node: 'D', revision: 1 }).ok, false)
153
+ assert.equal(advance(f).ok, false)
154
+ const completed = f.store.load().phases[0]
155
+ const repaired = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [completed, phases[1]], reason: '只为未完成阶段补可检查条件,保留已完成阶段原始依据' })
156
+ assert.equal(repaired.ok, true, repaired.reject)
157
+ assert.equal(repaired.exitCheckResults[0].matchedEvents, 0)
158
+ const saved = parse(readFileSync(f.store.path, 'utf8'))
159
+ assert.equal(saved.version, 3)
160
+ assert.deepEqual(saved.phases[0], completed)
161
+ assert.equal(saved.history.at(-1).previous.version, 2)
162
+ })
163
+
164
+ test('VP metric exits require baseline facts with the exact metric and finite value', () => {
165
+ const f = fixture()
166
+ const facts = [{ id: 'vp:old', eventId: 'old', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-08', signal: 'gap', summary: '已确认的漏读', fingerprint: 'old' }]
167
+ const store = createCurriculumStore({ ...f.options, loadStrategyEvidence: () => facts })
168
+ const phase = { ...phaseFor([]), id: 'vp-reading', competencies: ['contest-reading'], evidenceRefs: ['vp:old'],
169
+ exitChecks: [{ competency: 'contest-reading', minEvents: 2, metric: 'missed-reading', maxValue: 0 }] }
170
+ assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定读题', phases: [phase], reason: '依据已确认的漏读缺口,设两场零漏读的事实验收' }).ok, true)
171
+ const fresh = { ...facts[0], eventId: 'new', id: 'vp:new', date: '2026-10-09', signal: 'baseline' }
172
+ facts.push(fresh, { ...fresh, eventId: 'observation', id: 'vp:observation', signal: 'observation', metric: 'missed-reading', value: 0 })
173
+ assert.equal(store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 0)
174
+ facts.push({ ...fresh, id: 'vp:new-measured', metric: 'missed-reading', value: 0 }, { ...fresh, id: 'vp:duplicate', metric: 'missed-reading', value: 0 })
175
+ assert.equal(store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 1)
176
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '只有一场合格的新事实,验证不会误判两场条件' }).ok, false)
177
+ facts.push({ ...fresh, id: 'vp:another', eventId: 'another', metric: 'missed-reading', value: 0 })
178
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '两场不同的新事件都有零漏读的明确指标事实' }).status, 'complete')
179
+ })
180
+
181
+ test('malformed or uncovered structured exit rules are rejected before any write', () => {
182
+ for (const exitChecks of [[], [{ node: 'B', minEvents: 0 }], [{ node: 'C', minEvents: 1 }],
183
+ [{ node: 'B', competency: 'contest-reading', minEvents: 1 }], [{ node: 'B', minEvents: 1, minDistinctDays: 2 }],
184
+ [{ node: 'B', minEvents: 1, independent: 'true' }], [{ node: 'B', minEvents: 1, maxMinutes: 0 }],
185
+ [{ node: 'B', minEvents: 1, metric: 'missed-reading' }], [{ node: 'B', minEvents: 1, maxValue: 0 }],
186
+ [{ node: 'B', minEvents: 1 }, { node: 'B', minEvents: 2 }]]) {
187
+ const f = fixture(); const result = f.create({ phases: [phaseFor(['B'], { exitChecks })] })
188
+ assert.equal(result.ok, false, JSON.stringify(exitChecks))
189
+ assert.equal(f.store.load(), null)
190
+ }
191
+ })
192
+
193
+ test('future-dated events cannot meet training or diagnostic exit gates', () => {
194
+ for (const phase of [phaseFor(['B']), diagnostic()]) {
195
+ const f = fixture(); assert.equal(f.create({ phases: [phase] }).ok, true)
196
+ f.progress.nodes.B.status = 'verified'
197
+ f.progress.nodes.B.checks.push({ date: '2026-10-10', outcome: 'passed' })
198
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, ['B'])
199
+ assert.equal(advance(f).ok, false)
200
+ f.clock.at = '2026-10-10T12:00:00+08:00'
201
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, [])
202
+ }
203
+ })
204
+
205
+ test('exact occurrence times reject same-day earlier imports and future or malformed timestamps', () => {
206
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
207
+ f.progress.nodes.B.status = 'verified'
208
+ f.progress.nodes.B.passes = [
209
+ { date: '2026-10-09', problemId: 'P-earlier', solved: true, independent: true, minutes: 20, occurredAt: '2026-10-09T10:00:00+08:00' },
210
+ { date: '2026-10-09', problemId: 'P-future', solved: true, independent: true, minutes: 20, occurredAt: '2026-10-09T14:00:00+08:00' },
211
+ { date: '2026-10-09', problemId: 'P-invalid', solved: true, independent: true, minutes: 20, occurredAt: 'today' },
212
+ ]
213
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 0)
214
+ f.clock.at = '2026-10-09T15:00:00+08:00'
215
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 1)
216
+ assert.equal(advance(f).status, 'complete')
217
+ })
218
+
219
+ test('date-only performance exposes its timing precision instead of claiming exact chronology', () => {
220
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
221
+ prove(f.progress, 'B')
222
+ const result = f.store.execute({ action: 'read' })
223
+ assert.equal(result.exitCheckResults[0].matchedEvents, 1)
224
+ assert.ok(result.evidencePrecisionWarnings.some((warning) => warning.includes('同日')))
225
+ validateShape(curriculumTool(f.store).output.schema, result)
226
+ })
227
+
228
+ test('assess can retrieve the oldest unlabeled VP facts beyond the 200-item response window', () => {
229
+ const f = fixture(); f.progress.nodes = {}
230
+ const facts = Array.from({ length: 201 }, (_, index) => ({ id: `vp:fact-${index}`, eventId: `vp:contest-${index}`, source: 'vp',
231
+ node: '', competency: '', date: '2026-10-09', signal: 'observation', summary: `第${index}场自动同步事实`, fingerprint: `fact-${index}` }))
232
+ const store = createCurriculumStore({ ...f.options, loadStrategyEvidence: () => facts })
233
+ const all = store.execute({ action: 'assess' })
234
+ assert.equal(all.evidenceCount, 201); assert.equal(all.evidenceTruncated, true)
235
+ assert.ok(all.evidence.every((entry) => entry.eventId !== 'vp:contest-0'))
236
+ const tool = curriculumTool(store)
237
+ const selected = tool.execute({ action: 'assess', evidenceEventIds: ['vp:contest-0'] })
238
+ assert.equal(selected.ok, true, selected.reject)
239
+ assert.equal(selected.evidenceCount, 1); assert.equal(selected.evidenceTruncated, false)
240
+ assert.deepEqual(selected.evidence.map((entry) => entry.id), ['vp:fact-0'])
241
+ assert.ok(tool.parameters.evidenceEventIds)
242
+ validateShape(tool.output.schema, selected)
243
+ assert.equal(store.execute({ action: 'assess', evidenceEventIds: ['vp:contest-0'], evidenceNodes: ['B'] }).evidenceCount, 0)
244
+ assert.equal(store.execute({ action: 'assess', evidenceEventIds: ['vp:does-not-exist'] }).evidenceCount, 0)
245
+ assert.equal(store.execute({ action: 'assess' }).evidenceCount, 201)
246
+ })
247
+
248
+ test('assess rejects malformed event filters without changing the curriculum or exit evidence', () => {
249
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
250
+ const before = readFileSync(f.store.path, 'utf8')
251
+ for (const evidenceEventIds of ['vp:first', null, [1], [''], [' ']]) {
252
+ const result = f.store.execute({ action: 'assess', evidenceEventIds })
253
+ assert.equal(result.ok, false, JSON.stringify(evidenceEventIds))
254
+ assert.match(result.reject, /evidenceEventIds/)
255
+ }
256
+ prove(f.progress, 'B')
257
+ assert.equal(f.store.execute({ action: 'assess', evidenceEventIds: ['vp:unrelated'] }).evidenceCount, 0)
258
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, [])
259
+ assert.equal(readFileSync(f.store.path, 'utf8'), before)
260
+ assert.equal(advance(f).status, 'complete')
261
+ })
262
+
79
263
  test('assess exposes only existing record refs; rating and statuses cannot justify a training stage', () => {
80
264
  const f = fixture(); f.progress.nodes = { A: { status: 'learned' }, B: { status: 'studying', rating: 1600 } }
81
265
  const assessed = f.store.execute({ action: 'assess' })
@@ -220,7 +404,7 @@ test('v1 route remains readable without writes; migration preserves completed st
220
404
  const result = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [completed, phases[1]], reason: '为旧路线补齐未完成阶段的真实依据,保留已经完成的阶段' })
221
405
  assert.equal(result.ok, true, result.reject); assert.equal(result.revision, 2)
222
406
  const migrated = parse(readFileSync(f.store.path, 'utf8'))
223
- assert.equal(migrated.version, 2); assert.equal(migrated.phases[0].kind, 'legacy')
407
+ assert.equal(migrated.version, 3); assert.equal(migrated.phases[0].kind, 'legacy')
224
408
  assert.equal(migrated.history[1].previous.version, 1)
225
409
  })
226
410
 
@@ -347,6 +531,7 @@ test('competency-only phases can use fresh VP evidence as their exit gate', () =
347
531
  basis: 'VP 已记录读题缺口,需要在真实比赛节奏中修复', goalContribution: '提高区域赛前半小时的可做题识别和取舍质量',
348
532
  priorityReason: '它直接决定队伍是否把时间用在能拿分的题上', estimatedHours: 6, uncertainty: '', diagnosticMethod: '',
349
533
  evidenceRefs: ['vp:old:contest-reading'],
534
+ exitChecks: [{ competency: 'contest-reading', minEvents: 1 }],
350
535
  }
351
536
  assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定表现', baseline: '已有一场 VP 的读题缺口记录', weeklyHours: 30, phases: [phase], reason: '根据 VP 的读题记录建立能力课程' }).ok, true)
352
537
  assert.deepEqual(store.snapshot().phaseCompetencies, ['contest-reading'])
@@ -389,6 +574,9 @@ test('plugin integration: cross-branch course assignments, detours, logging and
389
574
  const course = tools.get('coach_curriculum'); const assign = tools.get('coach_assign')
390
575
  const created = await course.execute({ action: 'create', expectedRevision: 0, goal: '测试长期路线', phases, reason: '依据训练表现建立可延续的阶段课程' })
391
576
  assert.equal(created.ok, true, created.reject)
577
+ const selectedEvidence = await course.execute({ action: 'assess', evidenceEventIds: ['check:B:0'] })
578
+ assert.equal(selectedEvidence.evidenceCount, 1)
579
+ assert.deepEqual(selectedEvidence.evidence.map((entry) => entry.eventId), ['check:B:0'])
392
580
  const candidates = await tools.get('coach_next').execute({ cursor: 'B' })
393
581
  const nextText = tools.get('coach_next').output.render({}, candidates)[0].text
394
582
  assert.match(nextText, /测试长期路线/)
@@ -37,8 +37,7 @@ test('catalog only contains real record entries, with a stable typed public shap
37
37
  const rows = rowsFor({ checks: [check()], diagnoses: [diagnosis()], passes: [practice()] })
38
38
  assert.equal(rows.length, 3)
39
39
  for (const row of rows) {
40
- assert.deepEqual(Object.keys(row).sort(), ['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].sort())
41
- for (const key of Object.keys(row)) assert.equal(typeof row[key], 'string', key)
40
+ for (const key of ['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint']) assert.equal(typeof row[key], 'string', key)
42
41
  assert.ok(row.id && row.eventId && row.fingerprint && row.summary)
43
42
  assert.equal(row.node, '区间 DP'); assert.equal(row.date, date)
44
43
  assert.ok(['check', 'diagnosis', 'practice', 'schedule'].includes(row.source))
@@ -52,6 +51,14 @@ test('failed and over-time checks are gaps; passed checks only establish a basel
52
51
  assert.deepEqual(rows.map((row) => row.source), ['check', 'check', 'check'])
53
52
  })
54
53
 
54
+ test('structured performance fields are explicit facts rather than narrative guesses', () => {
55
+ const rows = rowsFor({ checks: [check('passed')], passes: [practice({ solved: true, independent: true, minutes: 32 })] })
56
+ assert.equal(rows[0].minutes, 45)
57
+ assert.equal(rows[0].independent, undefined)
58
+ assert.equal(rows[1].minutes, 32)
59
+ assert.equal(rows[1].independent, true)
60
+ })
61
+
55
62
  test('abandoned checks and narrative diagnoses without code evidence never become gaps', () => {
56
63
  assert.deepEqual(rowsFor({ checks: [check('abandoned', { reason: '今天临时没时间' })],
57
64
  diagnoses: [diagnosis({ evidence: [] }), diagnosis({ evidence: undefined }),
@@ -73,8 +73,8 @@ const onlyA = namesA.filter((n) => !namesB.includes(n))
73
73
  check('开源版没有丢掉任何原有工具', onlyB.length === 0, onlyB.join(',') || '(没丢)')
74
74
  // 新增的只能是「搭环境 / 拉数据 / 查知识 / 长期课程」这几类 —— 它们补的正是
75
75
  // 开源版缺的那些基础设施(原版靠本地已有的库和 skill,别人没有)。
76
- const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum'])
77
- check('新增的都在预期内(搭建 / 检索 / 导入 / 长期课程)',
76
+ const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum', 'coach_target', 'coach_focus', 'coach_scope', 'coach_postmortem', 'coach_vp_import', 'coach_vp_connect', 'coach_vp_sync', 'coach_vp_contests', 'coach_vp_replay'])
77
+ check('新增的都在预期内(搭建 / 检索 / 课程 / 比赛 / VP)',
78
78
  onlyA.every((n) => EXPECTED_NEW.has(n)), onlyA.join(',') || '(无新增)')
79
79
  // 对照副本可能也已经升级到 20/21 个工具;检查原工具集合,不能拿固定数量拒绝新版。
80
80
  const ORIGINAL_TOOLS = ['coach_ping', 'coach_next', 'coach_assign', 'coach_status',
@@ -128,6 +128,12 @@ for (const [tool, args, label] of CALLS) {
128
128
  let ra, rb, ea, eb
129
129
  try { ra = norm(await ta.execute(args, undefined)) } catch (e) { ea = e.message }
130
130
  try { rb = norm(await tb.execute(args, undefined)) } catch (e) { eb = e.message }
131
+ // Legacy hosts have no goal layer; only its explicit no-target defaults may be absent.
132
+ if (tool === 'coach_assign' && rb && !('targetAligned' in rb) &&
133
+ (ra?.targetAligned === true || (ra?.accepted === false && rb.accepted === false && ra.reject === rb.reject && ra.targetAligned === false)) &&
134
+ Array.isArray(ra.competencies) && !ra.competencies.length) {
135
+ delete ra.targetAligned; delete ra.competencies
136
+ }
131
137
  const sa = JSON.stringify(ra ?? { ERR: ea }), sb = JSON.stringify(rb ?? { ERR: eb })
132
138
  if (sa === sb) { check(`${tool} · ${label}`, true) }
133
139
  else {
@@ -136,7 +142,7 @@ for (const [tool, args, label] of CALLS) {
136
142
  const dk = [...new Set([...ka, ...kb])].filter((k) => JSON.stringify(ra?.[k]) !== JSON.stringify(rb?.[k]))
137
143
  const benign = dk.every((k) => ALLOWED.some(([n]) => k === n))
138
144
  if (benign) { check(`${tool} · ${label}`, true, `仅包名/版本不同(${dk.join(',')})`) }
139
- else { diff++; check(`${tool} · ${label}`, false, `差异字段:${dk.join(', ')}`) }
145
+ else { diff++; check(`${tool} · ${label}`, false, `差异字段:${dk.join(', ')}`); console.log(JSON.stringify({ current: Object.fromEntries(dk.map(k => [k, ra?.[k]])), legacy: Object.fromEntries(dk.map(k => [k, rb?.[k]])) })) }
140
146
  }
141
147
  }
142
148
 
package/dev/run-all.sh CHANGED
@@ -65,6 +65,11 @@ run "setup-check" node dev/setup-check.mjs
65
65
  run "curriculum" node --test dev/curriculum-check.mjs
66
66
  run "evidence" node --test dev/curriculum-evidence-check.mjs
67
67
  run "strategy" node --test dev/strategy-check.mjs
68
+ run "catalog" node --test dev/catalog-check.mjs
69
+ run "coach-contract" node --test dev/coaching-contract-check.mjs
70
+ run "VP-client" node dev/vp-client-check.mjs
71
+ run "VP-sync" node --test dev/vp-sync-check.mjs
72
+ run "migration" node --test dev/workspace-migration-check.mjs
68
73
  if [ "${COACH_LIVE_INDEX+x}" = x ] || node -e "
69
74
  const fs=require('fs'), os=require('os'), path=require('path');
70
75
  process.exit(fs.existsSync(path.join(os.homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-coach', 'index.js')) ? 0 : 1);
@@ -5,15 +5,149 @@ import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { createStrategyStore } from '../lib/strategy.js'
7
7
 
8
- function fixture() {
8
+ function fixture(options = {}) {
9
9
  const dir = mkdtempSync(join(tmpdir(), 'acmer-coach-strategy-'))
10
10
  const store = createStrategyStore({
11
11
  dataDir: dir,
12
12
  competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
13
+ ...options,
13
14
  })
14
15
  return { dir, store }
15
16
  }
16
17
 
18
+ test('invalid target constraints and VP numbers are rejected before changing ledgers', () => {
19
+ const { dir, store } = fixture()
20
+ try {
21
+ const target = { expectedRevision: 0, contest: 'VP', date: '2026-10-17', result: '稳定', weeklyHours: 25, priorities: [{ id: 'graph-modeling', weight: 10 }] }
22
+ for (const constraints of [[], null, 'bad']) assert.equal(store.target('set', { ...target, constraints }).ok, false)
23
+ assert.equal(store.target().ok, true)
24
+ assert.equal(store.target().revision, 0)
25
+ const event = { eventId: 'bad-numbers', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'AC' }] }
26
+ for (const [key, value] of [['solveMinutes', 'Infinity'], ['readMinutes', -1], ['attempts', 1.5], ['independent', 'yes'], ['occurredAt', 'yesterday']]) {
27
+ assert.equal(store.vpImport([{ ...event, problems: [{ ...event.problems[0], [key]: value }] }]).ok, false, key)
28
+ }
29
+ assert.equal(store.vpImport([{ ...event, durationMinutes: 'Infinity' }]).ok, false)
30
+ assert.equal(store.loadVp().revision, 0)
31
+ } finally { rmSync(dir, { recursive: true, force: true }) }
32
+ })
33
+
34
+ test('local training day includes morning VP evidence and computes the local deadline', () => {
35
+ const { dir, store } = fixture({ now: () => new Date(2026, 9, 11, 0, 30) })
36
+ try {
37
+ store.target('set', { expectedRevision: 0, contest: 'VP', date: '2026-10-17', result: '稳定', weeklyHours: 25, priorities: [{ id: 'graph-modeling', weight: 10 }] })
38
+ store.vpImport([{ eventId: 'today', contest: 'VP', date: '2026-10-11', problems: [{ problemId: 'A', status: 'AC', independent: true, competencies: ['graph-modeling'] }] }])
39
+ assert.equal(store.focus().focus[0].baselineCount, 1)
40
+ assert.equal(store.focus().daysRemaining, 6)
41
+ } finally { rmSync(dir, { recursive: true, force: true }) }
42
+ })
43
+
44
+ test('event measurements are explicit finite facts and sync preserves coach annotations', () => {
45
+ const { dir, store } = fixture()
46
+ try {
47
+ const event = { eventId: 'measured', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'AC' }] }
48
+ const measurements = [{ competency: 'contest-reading', metric: 'missed-reading', value: 0, passed: true, independent: true }]
49
+ assert.equal(store.vpImport([{ ...event, measurements }]).ok, true)
50
+ const metric = store.evidence().evidence.find(fact => fact.metric === 'missed-reading')
51
+ assert.ok(metric)
52
+ assert.equal(metric.signal, 'baseline')
53
+ assert.equal(metric.value, 0)
54
+ const invalid = store.vpImport([{ ...event, measurements: [{ ...measurements[0], value: 'Infinity' }] }])
55
+ assert.equal(invalid.ok, false)
56
+ store.vpImport([{ ...event, measurements, problems: [{ ...event.problems[0], independent: true, competencies: ['graph-modeling'] }] }])
57
+ const before = store.evidence().evidence.map(fact => fact.id)
58
+ store.vpImport([event], { source: 'sync' })
59
+ assert.deepEqual(store.evidence().evidence.map(fact => fact.id), before)
60
+ store.vpImport([{ ...event, problems: [{ problemId: 'A', status: 'WA' }] }], { source: 'sync' })
61
+ assert.equal(store.evidence().evidence.find(fact => fact.competency === 'graph-modeling').signal, 'observation')
62
+ assert.deepEqual(store.loadVp().events[0].measurements, measurements)
63
+ } finally { rmSync(dir, { recursive: true, force: true }) }
64
+ })
65
+
66
+ test('successful VP observations never become repair priority or reported problems', () => {
67
+ const { dir, store } = fixture({ now: () => new Date('2026-10-11T12:00:00Z') })
68
+ try {
69
+ store.target('set', { expectedRevision: 0, contest: '训练目标', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
70
+ priorities: [{ id: 'dp-modeling', weight: 10 }, { id: 'graph-modeling', weight: 10 }] })
71
+ store.vpImport(Array.from({ length: 5 }, (_, i) => ({ eventId: `dp-success-${i}`, contest: 'VP', date: '2026-10-10',
72
+ problems: [{ problemId: 'A', status: 'AC', independent: true, competencies: ['dp-modeling'] }] })))
73
+ const dp = store.focus().focus.find(item => item.id === 'dp-modeling')
74
+ assert.equal(dp.gapCount, 0)
75
+ assert.equal(dp.baselineCount, 5)
76
+ assert.doesNotMatch(dp.whyNow, /5 次相关问题|优先修复/)
77
+ assert.equal(dp.score, 10)
78
+ } finally { rmSync(dir, { recursive: true, force: true }) }
79
+ })
80
+
81
+ test('hypotheses stay observations and confirmation requires cited facts from the same VP', () => {
82
+ const { dir, store } = fixture({ now: () => new Date('2026-10-11T12:00:00Z') })
83
+ try {
84
+ store.vpImport([{ eventId: 'reason-1', contest: 'VP', date: '2026-10-10',
85
+ problems: [{ problemId: 'B', status: 'WA', competencies: ['graph-modeling'] }] }])
86
+ const fact = store.evidence().evidence[0]
87
+ assert.equal(fact.signal, 'observation')
88
+ const hypothesis = { competency: 'graph-modeling', status: 'hypothesis', cause: '可能把边建反了,需要查代码确认', evidenceRefs: [fact.id] }
89
+ assert.equal(store.postmortem({ eventId: 'reason-1', findings: [hypothesis] }).ok, true)
90
+ assert.equal(store.evidence().evidence.some(entry => entry.signal === 'gap'), false)
91
+ const forged = store.postmortem({ eventId: 'reason-1', findings: [{ ...hypothesis, status: 'confirmed', evidenceRefs: ['fabricated'] }] })
92
+ assert.equal(forged.ok, false)
93
+ assert.equal(store.postmortem({ eventId: 'reason-1', findings: [{ ...hypothesis, status: 'confirmed' }] }).ok, true)
94
+ const gap = store.evidence().evidence.find(entry => entry.signal === 'gap')
95
+ assert.ok(gap)
96
+ assert.match(gap.summary, /边建反/)
97
+ store.vpImport([{ eventId: 'reason-1', contest: 'VP', date: '2026-10-10',
98
+ problems: [{ problemId: 'B', status: 'AC', independent: true, competencies: ['graph-modeling'] }] }])
99
+ assert.equal(store.evidence().evidence.some(entry => entry.signal === 'gap'), false, 'changed facts invalidate old confirmation')
100
+ } finally { rmSync(dir, { recursive: true, force: true }) }
101
+ })
102
+
103
+ test('synced unlabelled facts can be cited without fabricating an algorithm tag', () => {
104
+ const { dir, store } = fixture()
105
+ try {
106
+ store.vpImport([{ eventId: 'unlabelled', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'WA' }] }])
107
+ const fact = store.evidence().evidence[0]
108
+ assert.ok(fact)
109
+ assert.equal(fact.competency, '')
110
+ assert.equal(store.postmortem({ eventId: 'unlabelled', findings: [{ competency: 'implementation-stability', status: 'confirmed',
111
+ cause: '检查代码后确认多组数据没有清空数组', evidenceRefs: [fact.id] }] }).ok, true)
112
+ } finally { rmSync(dir, { recursive: true, force: true }) }
113
+ })
114
+
115
+ test('confirmed gaps are counted once per recent event and old edits do not refresh them', () => {
116
+ const { dir, store } = fixture({ now: () => new Date('2026-10-11T12:00:00Z') })
117
+ try {
118
+ store.target('set', { expectedRevision: 0, contest: '目标', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
119
+ priorities: [{ id: 'graph-modeling', weight: 10 }, { id: 'dp-modeling', weight: 10 }] })
120
+ for (const [id, day] of [['recent', '2026-10-10'], ['old', '2026-08-01']]) {
121
+ store.vpImport([{ eventId: id, contest: 'VP', date: day, problems: ['A', 'B'].map(problemId => ({ problemId, status: 'WA', competencies: ['graph-modeling'] })) }])
122
+ const refs = store.evidence().evidence.filter(entry => entry.eventId === id && entry.source === 'vp').map(entry => entry.id)
123
+ assert.equal(store.postmortem({ eventId: id, date: '2026-10-11', findings: [{ competency: 'graph-modeling', status: 'confirmed',
124
+ cause: '核对题面与代码确认有向关系建反', evidenceRefs: refs }] }).ok, true)
125
+ }
126
+ const graph = store.focus().focus[0]
127
+ assert.equal(graph.id, 'graph-modeling')
128
+ assert.equal(graph.gapCount, 1)
129
+ assert.equal(graph.score, 20)
130
+ } finally { rmSync(dir, { recursive: true, force: true }) }
131
+ })
132
+
133
+ test('target profiles and exact node scopes retain sources without claiming an exam syllabus', () => {
134
+ const { dir, store } = fixture()
135
+ try {
136
+ writeFileSync(join(dir, 'NODE_SCOPES.yaml'), 'version: 1\nnodes:\n - id: 最短路\n title: 最短路识别\n sources: [{title: 原始资料, url: "https://oi-wiki.org/graph/shortest-path/"}]\n appliesTo: [路径代价]\n notFor: [负权直接套Dijkstra]\n')
137
+ writeFileSync(join(dir, 'CONTEST_PROFILES.yaml'), 'version: 1\nprofiles:\n - id: regional-sample\n title: 区域赛样本\n status: provisional\n scope: 样本范围\n sources: []\n competencyIds: [graph-modeling]\n selectionGuidance: [先诊断]\n limitations: [不是西安预测]\n')
138
+ const scoped = createStrategyStore({ dataDir: dir, competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
139
+ nodeScopesPath: join(dir, 'NODE_SCOPES.yaml'), contestProfilesPath: join(dir, 'CONTEST_PROFILES.yaml') })
140
+ assert.equal(scoped.scope('最短路').id, '最短路')
141
+ assert.equal(scoped.scope('最短路').sources.length, 1)
142
+ const saved = scoped.target('set', { expectedRevision: 0, contest: '西安', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
143
+ profileId: 'regional-sample', priorities: [{ id: 'graph-modeling', weight: 10 }] })
144
+ assert.equal(saved.ok, true)
145
+ assert.equal(saved.profileId, 'regional-sample')
146
+ assert.equal(scoped.target('profiles').availableProfiles[0].status, 'provisional')
147
+ assert.match(scoped.focus().profile.limitations[0], /不是西安预测/)
148
+ } finally { rmSync(dir, { recursive: true, force: true }) }
149
+ })
150
+
17
151
  test('target is explicit and revision protected', () => {
18
152
  const { dir, store } = fixture()
19
153
  try {
@@ -66,7 +200,7 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
66
200
  const { dir, store } = fixture()
67
201
  try {
68
202
  const empty = store.focus()
69
- assert.deepEqual(Object.keys(empty).sort(), ['contest', 'deadline', 'evidenceEvents', 'focus', 'ok', 'postmortems', 'postponed', 'reject'])
203
+ assert.deepEqual(Object.keys(empty).sort(), ['contest', 'daysRemaining', 'deadline', 'evidenceEvents', 'focus', 'ok', 'postmortems', 'postponed', 'profile', 'reject'])
70
204
  const rejected = store.target('set', {
71
205
  expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
72
206
  weeklyHours: 35, priorities: [{ id: 'invented-card', weight: 100 }],
@@ -116,8 +250,8 @@ test('strategy exposes stable competency evidence for curriculum gates', () => {
116
250
  problems: [{ problemId: 'C', status: 'WA', competencies: ['contest-decision'] }] }])
117
251
  const result = store.evidence()
118
252
  assert.equal(result.ok, true)
119
- assert.equal(result.evidence[0].id, 'vp:vp-evidence:C:contest-decision')
120
- assert.equal(result.evidence[0].signal, 'gap')
253
+ assert.match(result.evidence[0].id, /^vp:vp-evidence:C:contest-decision:[a-f0-9]{64}$/)
254
+ assert.equal(result.evidence[0].signal, 'observation')
121
255
  assert.equal(result.evidence[0].competency, 'contest-decision')
122
256
  } finally { rmSync(dir, { recursive: true, force: true }) }
123
257
  })
@@ -7,6 +7,7 @@ import { createVpClient } from '../lib/vp-client.js'
7
7
  const root = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-'))
8
8
  let account = { id: 1, handle: 'A', display_name: 'Coach A' }
9
9
  let mismatch = false
10
+ let nextPage = false
10
11
  const fetchImpl = async (url, options = {}) => {
11
12
  const path = new URL(url).pathname
12
13
  const body = options.body ? JSON.parse(options.body) : {}
@@ -19,8 +20,8 @@ const fetchImpl = async (url, options = {}) => {
19
20
  if (path.endsWith('/api/coach/contests')) return json({ account, contests: [{ id: 7, title: 'VP 7', status: 'ended' }] })
20
21
  if (path.endsWith('/api/coach/sync')) return json({
21
22
  account: mismatch ? { id: 1, handle: 'A' } : account,
22
- submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
23
- next_cursor: { submission_id: 9, event_id: 0 }, has_more: false,
23
+ submissions: [{ id: nextPage ? 10 : 9, contest_id: 7, verdict: nextPage ? 'WA' : 'AC' }], events: [], contests: [],
24
+ next_cursor: { submission_id: nextPage ? 10 : 9, event_id: 0 }, has_more: !nextPage,
24
25
  })
25
26
  if (path.endsWith('/api/coach/replay/7')) return json({ account, visualization: { team_events: [] } })
26
27
  throw new Error(`unexpected ${url}`)
@@ -34,7 +35,16 @@ try {
34
35
  await client.finish({ code: 'A' })
35
36
  const synced = await client.sync()
36
37
  assert.equal(synced.account.id, 1)
38
+ assert.equal(synced.hasMore, true, 'first page must expose continuation')
39
+ assert.deepEqual(synced.pageCounts, { submissions: 1, events: 0, contests: 0 })
37
40
  assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
41
+ nextPage = true
42
+ const secondPage = await client.sync()
43
+ assert.equal(secondPage.hasMore, false, 'last page must expose completion')
44
+ assert.deepEqual(secondPage.pageCounts, { submissions: 1, events: 0, contests: 0 }, 'page counts must not include old cached rows')
45
+ assert.deepEqual(secondPage.submissions.map(item => item.id), [9, 10], 'incremental pages must retain earlier accepted runs')
46
+ assert.deepEqual(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).submissions.map(item => item.id), [9, 10])
47
+ nextPage = false
38
48
  await client.finish({ code: 'B' })
39
49
  assert.equal((await client.status()).account.id, 2)
40
50
  assert.equal((await client.contests()).contests[0].id, 7)