acmer-coach 1.1.0 → 2.0.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.
- package/README.md +67 -5
- package/assets/knowledge/COMPETENCIES.yaml +92 -0
- package/assets/rules/coach-rules.md +55 -1
- package/dev/curriculum-check.mjs +403 -0
- package/dev/curriculum-evidence-check.mjs +197 -0
- package/dev/parity-check.mjs +15 -5
- package/dev/run-all.sh +17 -3
- package/dev/strategy-check.mjs +75 -0
- package/dev.sh +13 -3
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +146 -0
- package/index.js +167 -15
- package/lib/curriculum-evidence.js +141 -0
- package/lib/curriculum-planning.js +111 -0
- package/lib/curriculum.js +330 -0
- package/lib/strategy.js +201 -0
- package/package.json +1 -1
- package/verify-shape.mjs +11 -0
- package/verify.mjs +3 -3
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { parse, stringify } from 'yaml'
|
|
7
|
+
import { createCurriculumStore, curriculumTool } from '../lib/curriculum.js'
|
|
8
|
+
import { collectEvidence } from '../lib/curriculum-evidence.js'
|
|
9
|
+
|
|
10
|
+
const byId = new Map([
|
|
11
|
+
['A', { name: 'A', depends: [] }], ['B', { name: 'B', depends: ['A'] }],
|
|
12
|
+
['C', { name: 'C', depends: ['A'] }], ['D', { name: 'D', depends: ['B'] }],
|
|
13
|
+
['E', { name: 'E', depends: ['D'] }],
|
|
14
|
+
])
|
|
15
|
+
const testBase = process.env.COACH_TEST_TMP || tmpdir()
|
|
16
|
+
const seedNodes = {
|
|
17
|
+
A: { status: 'learned' },
|
|
18
|
+
B: { checks: [{ date: '2026-10-08', outcome: 'failed' }] },
|
|
19
|
+
C: { checks: [{ date: '2026-10-08', outcome: 'failed' }] },
|
|
20
|
+
D: { checks: [{ date: '2026-10-08', outcome: 'failed' }] },
|
|
21
|
+
}
|
|
22
|
+
const seedEvidence = collectEvidence({ nodes: seedNodes })
|
|
23
|
+
const phases = [
|
|
24
|
+
{ id: 'foundation', title: '稳定基础能力', outcome: '独立建模与实现', nodes: ['B', 'C'], exitCriteria: '独立变式与延迟复测', requiredVerified: ['B', 'C'] },
|
|
25
|
+
{ id: 'transfer', title: '组合迁移', outcome: '识别组合题机制', nodes: ['D'], exitCriteria: '无标签混合题独立完成', requiredVerified: ['D'] },
|
|
26
|
+
].map((phase) => ({ ...phase, kind: 'training', finding: 'gap',
|
|
27
|
+
basis: '已存在对应知识点的未通过检测记录,需要针对性训练',
|
|
28
|
+
goalContribution: '通过独立建模和稳定实现提升区域赛可完成题数',
|
|
29
|
+
priorityReason: '先修复已观测的基础问题,再进入有依赖的组合训练',
|
|
30
|
+
estimatedHours: 8, uncertainty: '', diagnosticMethod: '',
|
|
31
|
+
evidenceRefs: seedEvidence.filter((entry) => phase.nodes.includes(entry.node)).map((entry) => entry.id),
|
|
32
|
+
}))
|
|
33
|
+
function prove(progress, node) {
|
|
34
|
+
const entry = progress.nodes[node] ??= {}
|
|
35
|
+
entry.status = 'verified'
|
|
36
|
+
;(entry.checks ??= []).push({ date: '2026-10-09', outcome: 'passed' })
|
|
37
|
+
}
|
|
38
|
+
function fixture() {
|
|
39
|
+
const dataDir = mkdtempSync(join(testBase, 'coach-curriculum-'))
|
|
40
|
+
const progress = { nodes: structuredClone(seedNodes), pendingActions: {} }
|
|
41
|
+
const options = { dataDir, loadMap: () => ({ byId }), loadProgress: () => progress,
|
|
42
|
+
now: () => new Date('2026-10-09T12:00:00+08:00') }
|
|
43
|
+
const store = createCurriculumStore(options)
|
|
44
|
+
const create = (extra = {}) => store.execute({ action: 'create', expectedRevision: 0,
|
|
45
|
+
goal: '区域赛稳定表现,长期向 WF 发展', baseline: '水平与队伍缺口已核实,尚需独立检测',
|
|
46
|
+
weeklyHours: 30, phases, reason: '根据用户目标与真实训练表现建立课程路线', ...extra })
|
|
47
|
+
return { dataDir, progress, options, store, create }
|
|
48
|
+
}
|
|
49
|
+
function validateShape(schema, value) {
|
|
50
|
+
if (schema.type === 'object') {
|
|
51
|
+
assert.equal(typeof value, 'object'); assert.notEqual(value, null)
|
|
52
|
+
if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
|
|
53
|
+
for (const [key, child] of Object.entries(schema.properties)) {
|
|
54
|
+
if (child.required) assert.ok(key in value, `missing ${key}`)
|
|
55
|
+
if (key in value) validateShape(child, value[key])
|
|
56
|
+
}
|
|
57
|
+
} else if (schema.type === 'array') {
|
|
58
|
+
assert.ok(Array.isArray(value)); for (const item of value) validateShape(schema.items, item)
|
|
59
|
+
} else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
|
|
60
|
+
else assert.equal(typeof value, schema.type)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const phaseFor = (nodes, extra = {}) => ({ ...phases[0], nodes, requiredVerified: [...nodes],
|
|
64
|
+
evidenceRefs: seedEvidence.filter((entry) => nodes.includes(entry.node)).map((entry) => entry.id), ...extra })
|
|
65
|
+
const diagnostic = (nodes = ['B']) => phaseFor(nodes, {
|
|
66
|
+
id: 'diagnostic', title: '核实当前能力缺口', kind: 'diagnostic', finding: 'unknown',
|
|
67
|
+
basis: '尚无足够的实际结果证明具体短板,先进行诊断',
|
|
68
|
+
uncertainty: '尚未确认是否独立建模、实现稳定性或熟练度存在缺口',
|
|
69
|
+
diagnosticMethod: '独立完成小规模变式,记录结果并对失败代码进行判因',
|
|
70
|
+
evidenceRefs: [], requiredVerified: [], estimatedHours: 2,
|
|
71
|
+
})
|
|
72
|
+
const provisional = (nodes = ['D']) => phaseFor(nodes, {
|
|
73
|
+
id: 'future', kind: 'provisional', finding: 'unknown', basis: '该方向目前仅是与目标相关的远期假设,尚待诊断',
|
|
74
|
+
uncertainty: '尚未取得这些节点的训练结果,进入前须重新核实依据',
|
|
75
|
+
evidenceRefs: [], requiredVerified: [], estimatedHours: 0,
|
|
76
|
+
})
|
|
77
|
+
const advance = (f, revision = f.store.snapshot().revision) => f.store.execute({ action: 'advance', expectedRevision: revision, reason: '依据本阶段实际产生的检测与训练记录评估成果' })
|
|
78
|
+
|
|
79
|
+
test('assess exposes only existing record refs; rating and statuses cannot justify a training stage', () => {
|
|
80
|
+
const f = fixture(); f.progress.nodes = { A: { status: 'learned' }, B: { status: 'studying', rating: 1600 } }
|
|
81
|
+
const assessed = f.store.execute({ action: 'assess' })
|
|
82
|
+
assert.equal(assessed.evidenceCount, 0)
|
|
83
|
+
assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, false)
|
|
84
|
+
assert.equal(f.create({ phases: [diagnostic(), provisional()] }).ok, true)
|
|
85
|
+
assert.equal(f.store.snapshot().roadmap[1].kind, 'provisional')
|
|
86
|
+
assert.equal(f.store.execute({ action: 'assess', evidenceNodes: ['unknown'] }).ok, false)
|
|
87
|
+
validateShape(curriculumTool(f.store).output.schema, assessed)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('training rejects fake refs, unrelated evidence and missing per-node coverage', () => {
|
|
91
|
+
for (const phase of [
|
|
92
|
+
phaseFor(['B'], { evidenceRefs: ['imaginary-record'] }),
|
|
93
|
+
phaseFor(['B'], { evidenceRefs: [seedEvidence.find((entry) => entry.node === 'C').id] }),
|
|
94
|
+
phaseFor(['B', 'C'], { evidenceRefs: [seedEvidence.find((entry) => entry.node === 'B').id] }),
|
|
95
|
+
phaseFor(['B'], { requiredVerified: [] }),
|
|
96
|
+
]) {
|
|
97
|
+
const f = fixture(); assert.equal(f.create({ phases: [phase] }).ok, false)
|
|
98
|
+
assert.equal(f.store.load(), null)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('positive baselines permit extension but cannot be relabeled as a measured gap', () => {
|
|
103
|
+
const f = fixture(); prove(f.progress, 'B')
|
|
104
|
+
const ref = f.store.execute({ action: 'assess', evidenceNodes: ['B'] }).evidence.find((entry) => entry.signal === 'baseline').id
|
|
105
|
+
assert.equal(f.create({ phases: [phaseFor(['B'], { evidenceRefs: [ref] })] }).ok, false)
|
|
106
|
+
assert.equal(f.create({ phases: [phaseFor(['B'], { finding: 'extension', evidenceRefs: [ref] })] }).ok, true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('ordering uses explicit prerequisites and verified training gates, never cursor assumptions', () => {
|
|
110
|
+
const f = fixture(); delete f.progress.nodes.A; f.progress.cursor = 'B'
|
|
111
|
+
assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, false)
|
|
112
|
+
const g = fixture()
|
|
113
|
+
assert.equal(g.create({ phases: [phaseFor(['D'], { id: 'early-D' }), phaseFor(['B'])] }).ok, false)
|
|
114
|
+
assert.equal(g.create({ phases: [phaseFor(['D', 'B'])] }).ok, false)
|
|
115
|
+
assert.equal(g.create({ phases: [diagnostic(), phaseFor(['D'], { id: 'after-diagnosis' })] }).ok, false)
|
|
116
|
+
assert.equal(g.create({ phases: [phaseFor(['B', 'D'])] }).ok, true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('planning rationale and budget are mandatory, and provisional cannot be the active phase', () => {
|
|
120
|
+
for (const patch of [{ basis: '凭感觉' }, { goalContribution: '' }, { priorityReason: '' }, { estimatedHours: 0 }]) {
|
|
121
|
+
const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'], patch)] }).ok, false)
|
|
122
|
+
}
|
|
123
|
+
const f = fixture(); assert.equal(f.create({ phases: [provisional(['B'])] }).ok, false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('budget reports time pressure and uncertainty without pretending a weekly average is a daily schedule', () => {
|
|
127
|
+
const f = fixture(); const state = f.create({ weeklyHours: 7, deadline: '2026-10-09', phases: [phaseFor(['B'], { estimatedHours: 8 })] })
|
|
128
|
+
assert.equal(state.ok, true); assert.equal(state.remainingEstimatedHours, 8)
|
|
129
|
+
assert.match(state.budgetWarnings.join(' '), /高于目标日期/)
|
|
130
|
+
const g = fixture(); const unknown = g.create({ weeklyHours: 0, phases: [diagnostic(), provisional()] })
|
|
131
|
+
assert.equal(unknown.estimatedWeeks, 0); assert.match(unknown.budgetWarnings.join(' '), /未确认/)
|
|
132
|
+
assert.match(unknown.budgetWarnings.join(' '), /尚未估时/)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a changed source invalidates its reference and requires an explicit rebase', () => {
|
|
136
|
+
const f = fixture(); f.create()
|
|
137
|
+
f.progress.nodes.B.checks[0].minutes = 50
|
|
138
|
+
const state = f.store.snapshot(); assert.equal(state.needsReview, true)
|
|
139
|
+
assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, false)
|
|
140
|
+
assert.equal(advance(f).ok, false)
|
|
141
|
+
const refs = f.store.execute({ action: 'assess' }).evidence
|
|
142
|
+
const rebased = phases.map((phase) => ({ ...phase, evidenceRefs: refs.filter((entry) => phase.nodes.includes(entry.node)).map((entry) => entry.id) }))
|
|
143
|
+
assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, phases: rebased, reason: '原检测记录有实质修订,按当前真实内容重新引用' }).ok, true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('diagnostic cannot advance on old evidence, change into training or delete a probe', () => {
|
|
147
|
+
const f = fixture(); f.create({ phases: [diagnostic(['B', 'C']), provisional()] })
|
|
148
|
+
assert.equal(advance(f).ok, false)
|
|
149
|
+
assert.match(advance(f).reject, /新诊断/)
|
|
150
|
+
for (const replacement of [phaseFor(['B', 'C'], { id: 'diagnostic' }), diagnostic(['B'])]) {
|
|
151
|
+
assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, phases: [replacement, provisional()], reason: '尝试跳过尚未完成的诊断直接进入训练' }).ok, false)
|
|
152
|
+
}
|
|
153
|
+
assert.equal(f.store.snapshot().revision, 1)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('diagnosis results allow an evidence-grounded follow-up; next training requires new performance', () => {
|
|
157
|
+
const f = fixture(); f.create({ phases: [diagnostic()] })
|
|
158
|
+
f.progress.nodes.B.checks.push({ date: '2026-10-09', outcome: 'failed' })
|
|
159
|
+
assert.equal(advance(f).ok, false); assert.match(advance(f).reject, /后续路线/)
|
|
160
|
+
const training = phaseFor(['B'], { id: 'fix-B', evidenceRefs: f.store.execute({ action: 'assess', evidenceNodes: ['B'] }).evidence.map((entry) => entry.id) })
|
|
161
|
+
const revised = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [diagnostic(), training], reason: '根据本阶段新诊断结果追加针对性的训练阶段' })
|
|
162
|
+
assert.equal(revised.ok, true, revised.reject)
|
|
163
|
+
const entered = advance(f); assert.equal(entered.ok, true, entered.reject); assert.equal(entered.phaseId, 'fix-B')
|
|
164
|
+
assert.equal(advance(f).ok, false)
|
|
165
|
+
prove(f.progress, 'B')
|
|
166
|
+
assert.equal(advance(f).status, 'complete')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('a provisional next phase cannot be entered even after diagnostic records exist', () => {
|
|
170
|
+
const f = fixture(); f.create({ phases: [diagnostic(), provisional()] })
|
|
171
|
+
f.progress.nodes.B.checks.push({ date: '2026-10-09', outcome: 'failed' })
|
|
172
|
+
const result = advance(f); assert.equal(result.ok, false); assert.match(result.reject, /下一阶段仍待定/)
|
|
173
|
+
assert.equal(f.store.snapshot().phaseId, 'diagnostic')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('verified flags and old/backdated checks cannot substitute for new stage performance', () => {
|
|
177
|
+
const f = fixture(); prove(f.progress, 'B'); f.create({ phases: [phaseFor(['B'])] })
|
|
178
|
+
assert.equal(advance(f).ok, false)
|
|
179
|
+
f.progress.nodes.B.checks.push({ date: '2026-10-08', outcome: 'passed' })
|
|
180
|
+
assert.equal(advance(f).ok, false)
|
|
181
|
+
prove(f.progress, 'B'); assert.equal(advance(f).status, 'complete')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('note edits, changed old results and mirrored imports do not constitute new diagnostic events', () => {
|
|
185
|
+
const f = fixture()
|
|
186
|
+
f.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P1', solved: false, independent: false, minutes: 40, note: '初始记录' }]
|
|
187
|
+
f.create({ phases: [diagnostic(), provisional()] })
|
|
188
|
+
f.progress.nodes.B.passes[0].note = '仅修改说明'
|
|
189
|
+
assert.deepEqual(f.store.snapshot().missingFreshEvidence, ['B'])
|
|
190
|
+
f.progress.nodes.B.passes[0].solved = true; f.progress.nodes.B.passes[0].independent = true
|
|
191
|
+
assert.deepEqual(f.store.snapshot().missingFreshEvidence, ['B'])
|
|
192
|
+
const g = fixture(); g.progress.nodes.B = {}
|
|
193
|
+
const schedule = { days: { '2026-10-09': { actual: [{ node: 'B', problemId: 'P1', solved: false, independent: false, actualMin: 40 }] } } }
|
|
194
|
+
const store = createCurriculumStore({ ...g.options, loadSchedule: () => schedule })
|
|
195
|
+
assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '核实长期训练所需的能力起点', phases: [diagnostic(), provisional()], reason: '在只有日程记录的情况下先建立能力诊断阶段' }).ok, true)
|
|
196
|
+
g.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P1', solved: false, independent: false, minutes: 40 }]
|
|
197
|
+
assert.deepEqual(store.snapshot().missingFreshEvidence, ['B'])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('adding a training target snapshots its existing observations; budget-only revision preserves progress', () => {
|
|
201
|
+
const f = fixture(); f.create({ phases: [phaseFor(['B'])] }); prove(f.progress, 'C')
|
|
202
|
+
assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, phases: [phaseFor(['B', 'C'])], reason: '根据实际记录扩展当前阶段能力目标,重新验收新增节点' }).ok, true)
|
|
203
|
+
assert.deepEqual(f.store.snapshot().missingFreshEvidence, ['B', 'C'])
|
|
204
|
+
const g = fixture(); g.create({ phases: [phaseFor(['B'])] }); prove(g.progress, 'B')
|
|
205
|
+
assert.equal(g.store.execute({ action: 'revise', expectedRevision: 1, phases: [phaseFor(['B'], { estimatedHours: 10 })], reason: '可用时间改变,仅调整时间估算,保持能力目标和依据' }).ok, true)
|
|
206
|
+
assert.deepEqual(g.store.snapshot().missingFreshEvidence, [])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('v1 route remains readable without writes; migration preserves completed stages and history', () => {
|
|
210
|
+
const f = fixture(); f.create(); prove(f.progress, 'B'); prove(f.progress, 'C')
|
|
211
|
+
const old = parse(readFileSync(f.store.path, 'utf8')); old.version = 1; old.current = 1
|
|
212
|
+
old.phases = old.phases.map(({ id, title, outcome, nodes, exitCriteria, requiredVerified }) => ({ id, title, outcome, nodes, exitCriteria, requiredVerified }))
|
|
213
|
+
delete old.enteredAt; delete old.entryEvidenceRefs; delete old.entryEvidenceEvents
|
|
214
|
+
const bytes = stringify(old); writeFileSync(f.store.path, bytes)
|
|
215
|
+
const state = f.store.snapshot(); assert.equal(state.needsReview, true)
|
|
216
|
+
assert.equal(readFileSync(f.store.path, 'utf8'), bytes)
|
|
217
|
+
assert.equal(f.store.assignment({ node: 'D', revision: 1 }).ok, false)
|
|
218
|
+
assert.equal(advance(f).ok, false)
|
|
219
|
+
const completed = f.store.load().phases[0]
|
|
220
|
+
const result = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [completed, phases[1]], reason: '为旧路线补齐未完成阶段的真实依据,保留已经完成的阶段' })
|
|
221
|
+
assert.equal(result.ok, true, result.reject); assert.equal(result.revision, 2)
|
|
222
|
+
const migrated = parse(readFileSync(f.store.path, 'utf8'))
|
|
223
|
+
assert.equal(migrated.version, 2); assert.equal(migrated.phases[0].kind, 'legacy')
|
|
224
|
+
assert.equal(migrated.history[1].previous.version, 1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('course persists across store instances without duplicating progress', () => {
|
|
228
|
+
const f = fixture(); assert.equal(f.create().ok, true)
|
|
229
|
+
const restarted = createCurriculumStore(f.options)
|
|
230
|
+
assert.equal(restarted.snapshot().goal, '区域赛稳定表现,长期向 WF 发展')
|
|
231
|
+
assert.equal(restarted.snapshot().phaseId, 'foundation')
|
|
232
|
+
assert.deepEqual(restarted.snapshot().missingVerified, ['B', 'C'])
|
|
233
|
+
assert.equal(parse(readFileSync(f.store.path, 'utf8')).nodes, undefined)
|
|
234
|
+
Object.assign(f.progress.nodes.B, { status: 'verified' })
|
|
235
|
+
assert.deepEqual(restarted.snapshot().missingVerified, ['C'])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('stale writes cannot overwrite a changed goal, and revision history keeps old snapshots', () => {
|
|
239
|
+
const f = fixture(); f.create()
|
|
240
|
+
const edited = f.store.execute({ action: 'revise', expectedRevision: 1, goal: '按证据修订后的能力目标', reason: '用户修改目标,保留当前阶段并调整远期路线' })
|
|
241
|
+
assert.equal(edited.revision, 2)
|
|
242
|
+
assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, goal: '旧目标', reason: '使用旧版本尝试更新课程目标' }).ok, false)
|
|
243
|
+
assert.equal(f.store.snapshot().goal, '按证据修订后的能力目标')
|
|
244
|
+
const doc = parse(readFileSync(f.store.path, 'utf8'))
|
|
245
|
+
assert.equal(doc.history[1].previous.goal, '区域赛稳定表现,长期向 WF 发展')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('unknown map nodes, duplicate phases, invalid dates and future phase focus are rejected', () => {
|
|
249
|
+
for (const extra of [
|
|
250
|
+
{ phases: [{ ...phases[0], nodes: ['unknown'] }] },
|
|
251
|
+
{ phases: [phases[0], phases[0]] }, { deadline: '2026-02-30' },
|
|
252
|
+
{ focusNodes: ['D'] }, { weeklyHours: 169 },
|
|
253
|
+
]) {
|
|
254
|
+
const f = fixture(); assert.equal(f.create(extra).ok, false); assert.equal(f.store.load(), null)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('active course cannot be silently replaced, and failed validation preserves the file', () => {
|
|
259
|
+
const f = fixture(); f.create(); const before = readFileSync(f.store.path, 'utf8')
|
|
260
|
+
assert.equal(f.create({ expectedRevision: 1 }).ok, false)
|
|
261
|
+
assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, reason: '尚未给出足够修改依据', focusNodes: ['D'] }).ok, false)
|
|
262
|
+
assert.equal(readFileSync(f.store.path, 'utf8'), before)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('assignment is bound to current course revision and focus, with explicit detours', () => {
|
|
266
|
+
const f = fixture(); f.create({ focusNodes: ['B'] })
|
|
267
|
+
assert.equal(f.store.assignment({ node: 'B' }).ok, false)
|
|
268
|
+
assert.equal(f.store.assignment({ node: 'C', revision: 1 }).ok, false)
|
|
269
|
+
assert.equal(f.store.assignment({ node: 'D', revision: 1, purpose: 'remediation' }).ok, false)
|
|
270
|
+
const detour = f.store.assignment({ node: 'C', revision: 1, purpose: 'remediation', reason: '补当前阶段暴露的机制缺口,完成后回到 B' })
|
|
271
|
+
assert.equal(detour.ok, true); assert.equal(detour.binding.phaseId, 'foundation')
|
|
272
|
+
assert.equal(f.store.snapshot().phaseId, 'foundation')
|
|
273
|
+
assert.equal(f.store.assignment({ node: 'D', revision: 1, purpose: 'review', reason: '暂时复习同类知识点,之后返回主线' }).ok, false)
|
|
274
|
+
assert.equal(f.store.assignment({ node: 'A', revision: 1, purpose: 'review', reason: '复习已经学过的前置,随后回到 B 的变式' }).ok, true)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('expired weekly focus pauses new progression without changing long-term course', () => {
|
|
278
|
+
const f = fixture(); f.create({ focusNodes: ['B'], focusUntil: '2026-10-08' })
|
|
279
|
+
assert.equal(f.store.snapshot().focusExpired, true)
|
|
280
|
+
assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, false)
|
|
281
|
+
assert.match(f.store.context(), /本周主线已到期/)
|
|
282
|
+
assert.equal(f.store.snapshot().goal, '区域赛稳定表现,长期向 WF 发展')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('unfinished bound action stops a new unit; completing it permits the next one', () => {
|
|
286
|
+
const f = fixture(); f.create()
|
|
287
|
+
f.progress.pendingActions.B = { curriculum: { phaseId: 'foundation' } }
|
|
288
|
+
assert.equal(f.store.assignment({ node: 'C', revision: 1 }).ok, false)
|
|
289
|
+
assert.match(f.store.context(), /先继续/)
|
|
290
|
+
delete f.progress.pendingActions.B
|
|
291
|
+
assert.equal(f.store.assignment({ node: 'C', revision: 1 }).ok, true)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('existing assignments survive introducing a course and no-course behavior stays available', () => {
|
|
295
|
+
const f = fixture()
|
|
296
|
+
assert.equal(f.store.assignment({ node: 'B' }).ok, true)
|
|
297
|
+
assert.equal(f.store.assignment({ node: 'B' }).binding, null)
|
|
298
|
+
f.progress.pendingActions.A = { deliverable: '升级前已布置的动作' }
|
|
299
|
+
f.create()
|
|
300
|
+
assert.deepEqual(f.store.snapshot().pendingNodes, ['A'])
|
|
301
|
+
assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, false)
|
|
302
|
+
delete f.progress.pendingActions.A
|
|
303
|
+
assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('phase advancement requires verified facts, no pending actions, and no ungraded paper', () => {
|
|
307
|
+
const f = fixture(); f.create()
|
|
308
|
+
const advance = () => f.store.execute({ action: 'advance', expectedRevision: 1, reason: '根据独立变式及延迟检测记录评估阶段成果' })
|
|
309
|
+
assert.equal(advance().ok, false)
|
|
310
|
+
prove(f.progress, 'B'); prove(f.progress, 'C')
|
|
311
|
+
f.progress.pendingActions.B = { curriculum: { phaseId: 'foundation' } }
|
|
312
|
+
assert.equal(advance().ok, false)
|
|
313
|
+
delete f.progress.pendingActions.B; f.progress.pending = { node: 'B' }
|
|
314
|
+
assert.equal(advance().ok, false)
|
|
315
|
+
f.progress.pending = null
|
|
316
|
+
const next = advance(); assert.equal(next.ok, true); assert.equal(next.phaseId, 'transfer')
|
|
317
|
+
assert.deepEqual(next.missingVerified, ['D'])
|
|
318
|
+
assert.equal(f.store.execute({ action: 'revise', expectedRevision: 2, phases: [{ ...phases[0], outcome: '重写过去' }, phases[1]], reason: '尝试通过修订重写之前已完成的能力阶段' }).ok, false)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('all phases can finish explicitly and a new route retains the previous audit history', () => {
|
|
322
|
+
const f = fixture(); f.create({ phases: [phases[0]] })
|
|
323
|
+
prove(f.progress, 'B'); prove(f.progress, 'C')
|
|
324
|
+
assert.equal(f.store.snapshot().status, 'active')
|
|
325
|
+
const done = f.store.execute({ action: 'advance', expectedRevision: 1, reason: '依据两次独立检测及迁移任务完成最终验收' })
|
|
326
|
+
assert.equal(done.status, 'complete')
|
|
327
|
+
assert.equal(f.store.assignment({ node: 'B', revision: 2 }).ok, false)
|
|
328
|
+
assert.equal(f.create({ expectedRevision: 2 }).ok, true)
|
|
329
|
+
assert.equal(f.store.snapshot().history.length, 3)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('broken course is surfaced and never treated as an empty plan', () => {
|
|
333
|
+
const f = fixture(); writeFileSync(f.store.path, 'revision: broken\n')
|
|
334
|
+
const result = f.create(); assert.equal(result.ok, false)
|
|
335
|
+
assert.match(f.store.context(), /读取失败/)
|
|
336
|
+
assert.equal(readFileSync(f.store.path, 'utf8'), 'revision: broken\n')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('course tool output matches its declared schema on success, rejection and absence', () => {
|
|
340
|
+
const f = fixture(); const tool = curriculumTool(f.store)
|
|
341
|
+
for (const result of [f.store.execute({ action: 'read' }), f.create(), f.store.execute({ action: 'advance', expectedRevision: 1, reason: '检查未验收阶段是否能够被强行推进' })]) validateShape(tool.output.schema, result)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('partly damaged course fields are rejected with a valid output and never overwritten', () => {
|
|
345
|
+
for (const change of [
|
|
346
|
+
(doc) => { doc.goal = 123 }, (doc) => { delete doc.baseline },
|
|
347
|
+
(doc) => { doc.history = [null] }, (doc) => { doc.updated = 'invalid' },
|
|
348
|
+
(doc) => { doc.focusNodes = ['B', 'B'] }, (doc) => { doc.weeklyHours = 169 },
|
|
349
|
+
]) {
|
|
350
|
+
const f = fixture(); f.create()
|
|
351
|
+
const doc = parse(readFileSync(f.store.path, 'utf8')); change(doc)
|
|
352
|
+
const broken = stringify(doc); writeFileSync(f.store.path, broken)
|
|
353
|
+
const result = f.store.execute({ action: 'read' })
|
|
354
|
+
assert.equal(result.ok, false)
|
|
355
|
+
validateShape(curriculumTool(f.store).output.schema, result)
|
|
356
|
+
assert.equal(readFileSync(f.store.path, 'utf8'), broken)
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('plugin integration: cross-branch course assignments, detours, logging and new-session context', async () => {
|
|
361
|
+
const dataDir = mkdtempSync(join(testBase, 'coach-course-integration-'))
|
|
362
|
+
writeFileSync(join(dataDir, 'MAP.yaml'), 'meta: {version: 3}\nnodes:\n' + [...byId].map(([id, n]) => ` - id: ${id}\n name: ${id}\n domain: 测试\n tier: core\n depends: [${n.depends.join(', ')}]\n`).join(''))
|
|
363
|
+
writeFileSync(join(dataDir, 'PROGRESS.yaml'), stringify({ version: 1, cursor: 'B', nodes: { ...structuredClone(seedNodes), B: { ...seedNodes.B, status: 'studying' }, C: { ...seedNodes.C, status: 'studying' } } }))
|
|
364
|
+
process.env.COACH_DATA_DIR = dataDir
|
|
365
|
+
process.env.COACH_DB = join(dataDir, 'unused.db')
|
|
366
|
+
const tools = new Map(); let hook
|
|
367
|
+
const { apply } = await import('../index.js?curriculum-integration')
|
|
368
|
+
apply({ tools: { register: (t) => tools.set(t.name, t) }, inject() {}, on: (event, fn) => { if (event === 'agent/pre-step') hook = fn } })
|
|
369
|
+
const course = tools.get('coach_curriculum'); const assign = tools.get('coach_assign')
|
|
370
|
+
const created = await course.execute({ action: 'create', expectedRevision: 0, goal: '测试长期路线', phases, reason: '依据训练表现建立可延续的阶段课程' })
|
|
371
|
+
assert.equal(created.ok, true, created.reject)
|
|
372
|
+
const candidates = await tools.get('coach_next').execute({ cursor: 'B' })
|
|
373
|
+
const nextText = tools.get('coach_next').output.render({}, candidates)[0].text
|
|
374
|
+
assert.match(nextText, /测试长期路线/)
|
|
375
|
+
assert.doesNotMatch(nextText, /得先把游标挪过去/)
|
|
376
|
+
const gradeText = tools.get('coach_grade').output.render({}, {
|
|
377
|
+
ok: true, outcome: 'passed', nodeName: 'B', score: 3, maxScore: 3, passScore: 2,
|
|
378
|
+
minutes: 40, limit: 60, checks: 1, problems: [], verifiedAt: 1600, selfReported: 0,
|
|
379
|
+
})[0].text
|
|
380
|
+
assert.match(gradeText, /单点过卷不会自动推进阶段/)
|
|
381
|
+
assert.doesNotMatch(gradeText, /推进,别回头/)
|
|
382
|
+
const args = { node: 'C', deliverable: '独立变式代码与思路', why: '检验当前阶段的建模能力', curriculumRevision: 1 }
|
|
383
|
+
const accepted = await assign.execute(args); assert.equal(accepted.accepted, true, accepted.reject)
|
|
384
|
+
assert.match(accepted.why, /稳定基础能力/)
|
|
385
|
+
assert.equal(parse(readFileSync(join(dataDir, 'PROGRESS.yaml'), 'utf8')).pendingActions.C.curriculum.phaseId, 'foundation')
|
|
386
|
+
assert.equal((await assign.execute({ ...args, node: 'B' })).accepted, false)
|
|
387
|
+
const decision = await hook({ messages: [], step: 1 }, async () => ({ kind: 'enter', messages: [] }))
|
|
388
|
+
assert.match(JSON.stringify(decision.messages), /测试长期路线/)
|
|
389
|
+
assert.match(JSON.stringify(decision.messages), /当前未完成单元/)
|
|
390
|
+
const logged = await tools.get('coach_log').execute({ node: 'C', actualMin: 40, solved: true, independent: true })
|
|
391
|
+
assert.equal(logged.actionCleared, true)
|
|
392
|
+
assert.equal((await course.execute({ action: 'read' })).phaseId, 'foundation')
|
|
393
|
+
const offRoute = await assign.execute({ ...args, node: 'D', teachMinutes: 10 })
|
|
394
|
+
assert.equal(offRoute.accepted, false); assert.match(offRoute.reject, /当前阶段主线/)
|
|
395
|
+
const remediation = await assign.execute({ ...args, node: 'E', teachMinutes: 10, purpose: 'remediation', routeReason: '补当前阶段建模失败的机制,检验后回到主线' })
|
|
396
|
+
assert.equal(remediation.accepted, false); assert.match(remediation.reject, /前置/)
|
|
397
|
+
validateShape(assign.output.schema, accepted); validateShape(assign.output.schema, offRoute)
|
|
398
|
+
writeFileSync(join(dataDir, 'PROGRESS.yaml'), 'version: 1\ncursor: ""\nnodes: {}\n')
|
|
399
|
+
const fresh = await hook({ messages: [], step: 1 }, async () => ({ kind: 'enter', messages: [] }))
|
|
400
|
+
assert.match(JSON.stringify(fresh.messages), /测试长期路线/)
|
|
401
|
+
const later = await hook({ messages: [], step: 2 }, async () => ({ kind: 'enter', messages: [] }))
|
|
402
|
+
assert.equal(later.messages.length, 0)
|
|
403
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { collectEvidence } from '../lib/curriculum-evidence.js'
|
|
4
|
+
|
|
5
|
+
const date = '2026-10-01'
|
|
6
|
+
const check = (outcome = 'failed', extra = {}) => ({
|
|
7
|
+
date, outcome, score: outcome === 'passed' ? 100 : 40, maxScore: 100,
|
|
8
|
+
passScore: 80, minutes: 45, limit: 60, ...extra,
|
|
9
|
+
})
|
|
10
|
+
const diagnosis = (extra = {}) => ({
|
|
11
|
+
date, at: `${date}T12:00:00+08:00`, problem: '1234A', category: '不会',
|
|
12
|
+
summary: '遗漏边界状态,空区间访问导致答案错误',
|
|
13
|
+
evidence: [{ quote: 'answer += dp[left][right];', kind: 'wrong', why: '没有过滤空区间,读取了无效状态' }],
|
|
14
|
+
...extra,
|
|
15
|
+
})
|
|
16
|
+
const practice = (extra = {}) => ({ date, problemId: '1234A', solved: false,
|
|
17
|
+
independent: true, minutes: 40, ...extra })
|
|
18
|
+
const progress = (nodeRecord, node = '区间 DP') => ({ nodes: { [node]: nodeRecord } })
|
|
19
|
+
const rowsFor = (record) => collectEvidence(progress(record))
|
|
20
|
+
const reverseKeys = (value) => Array.isArray(value) ? value.map(reverseKeys)
|
|
21
|
+
: value && typeof value === 'object'
|
|
22
|
+
? Object.fromEntries(Object.entries(value).reverse().map(([key, item]) => [key, reverseKeys(item)]))
|
|
23
|
+
: value
|
|
24
|
+
const deepFreeze = (value) => {
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
for (const child of Object.values(value)) deepFreeze(child)
|
|
27
|
+
Object.freeze(value)
|
|
28
|
+
}
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('catalog only contains real record entries, with a stable typed public shape', () => {
|
|
33
|
+
assert.deepEqual(collectEvidence({}), [])
|
|
34
|
+
assert.deepEqual(collectEvidence({ rating: 1600, cursor: '区间 DP',
|
|
35
|
+
nodes: { '区间 DP': { status: 'studying', at: date, verifiedAt: 1900 } },
|
|
36
|
+
pendingActions: { '区间 DP': { kind: '练习', at: date } } }), [])
|
|
37
|
+
const rows = rowsFor({ checks: [check()], diagnoses: [diagnosis()], passes: [practice()] })
|
|
38
|
+
assert.equal(rows.length, 3)
|
|
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)
|
|
42
|
+
assert.ok(row.id && row.eventId && row.fingerprint && row.summary)
|
|
43
|
+
assert.equal(row.node, '区间 DP'); assert.equal(row.date, date)
|
|
44
|
+
assert.ok(['check', 'diagnosis', 'practice', 'schedule'].includes(row.source))
|
|
45
|
+
assert.ok(['gap', 'baseline'].includes(row.signal))
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('failed and over-time checks are gaps; passed checks only establish a baseline', () => {
|
|
50
|
+
const rows = rowsFor({ checks: [check('failed'), check('overTime', { minutes: 90 }), check('passed')] })
|
|
51
|
+
assert.deepEqual(rows.map((row) => row.signal), ['gap', 'gap', 'baseline'])
|
|
52
|
+
assert.deepEqual(rows.map((row) => row.source), ['check', 'check', 'check'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('abandoned checks and narrative diagnoses without code evidence never become gaps', () => {
|
|
56
|
+
assert.deepEqual(rowsFor({ checks: [check('abandoned', { reason: '今天临时没时间' })],
|
|
57
|
+
diagnoses: [diagnosis({ evidence: [] }), diagnosis({ evidence: undefined }),
|
|
58
|
+
{ date, category: '不会', summary: 'rating 不够高所以 DP 不熟' }] }), [])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('diagnosis evidence requires a meaningful quote, valid kind, and explanation', () => {
|
|
62
|
+
for (const invalid of [
|
|
63
|
+
{ quote: '', kind: 'wrong', why: '边界遗漏' },
|
|
64
|
+
{ quote: ' ', kind: 'wrong', why: '边界遗漏' },
|
|
65
|
+
{ quote: 'answer += dp[left][right];', kind: '', why: '边界遗漏' },
|
|
66
|
+
{ quote: 'answer += dp[left][right];', kind: 'guessed', why: '边界遗漏' },
|
|
67
|
+
{ quote: 'answer += dp[left][right];', kind: 'wrong', why: '' },
|
|
68
|
+
{ quote: 'answer += dp[left][right];', kind: 'wrong', why: ' ' },
|
|
69
|
+
null, '代码大概有问题',
|
|
70
|
+
]) assert.deepEqual(rowsFor({ diagnoses: [diagnosis({ evidence: [invalid] })] }), [])
|
|
71
|
+
for (const kind of ['wrong', 'missing']) {
|
|
72
|
+
const rows = rowsFor({ diagnoses: [diagnosis({ evidence: [{ quote: 'answer += dp[left][right];', kind, why: '没有考虑空区间状态' }] })] })
|
|
73
|
+
assert.equal(rows.length, 1); assert.equal(rows[0].signal, 'gap')
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('practice establishes baseline only when solved and independent are both explicitly true', () => {
|
|
78
|
+
const values = [[true, true], [true, false], [false, true], [false, false]]
|
|
79
|
+
const rows = rowsFor({ passes: values.map(([solved, independent], index) => practice({
|
|
80
|
+
solved, independent, problemId: `${index}A`,
|
|
81
|
+
})) })
|
|
82
|
+
assert.deepEqual(rows.map((row) => row.signal), ['baseline', 'gap', 'gap', 'gap'])
|
|
83
|
+
assert.ok(rows.every((row) => row.source === 'practice'))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('invalid containers, non-record values, missing dates and illegal records are skipped', () => {
|
|
87
|
+
for (const value of [null, undefined, false, 42, 'not progress', []]) assert.deepEqual(collectEvidence(value), [])
|
|
88
|
+
assert.deepEqual(collectEvidence({ nodes: null }, { days: null }), [])
|
|
89
|
+
assert.deepEqual(collectEvidence({ nodes: { A: null, B: false, C: 'not a record' } }), [])
|
|
90
|
+
assert.deepEqual(rowsFor({ checks: {}, diagnoses: true, passes: 3 }), [])
|
|
91
|
+
assert.deepEqual(rowsFor({ checks: [null, 42, {}, check('failed', { date: '' }),
|
|
92
|
+
check('unknown'), check('failed', { date: '2026-02-30' })],
|
|
93
|
+
diagnoses: [null, {}, diagnosis({ date: undefined })],
|
|
94
|
+
passes: [null, {}, practice({ date: '' }), practice({ solved: 'true' }), practice({ independent: null })] }), [])
|
|
95
|
+
assert.deepEqual(collectEvidence({}, { days: { [date]: { actual: [null, {},
|
|
96
|
+
{ node: '', actualMin: 30, solved: false, independent: false },
|
|
97
|
+
{ node: 'A', solved: false, independent: false },
|
|
98
|
+
] }, 'not-a-date': { actual: [{ node: 'A', actualMin: 30, solved: false, independent: false }] } } }), [])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('invalid numeric facts and circular values cannot produce usable evidence references', () => {
|
|
102
|
+
for (const amount of [-1, NaN, Infinity, '40']) {
|
|
103
|
+
assert.deepEqual(rowsFor({ checks: [check('failed', { minutes: amount })] }), [])
|
|
104
|
+
assert.deepEqual(rowsFor({ passes: [practice({ minutes: amount })] }), [])
|
|
105
|
+
}
|
|
106
|
+
assert.deepEqual(rowsFor({ passes: [practice({ minutes: 0 })] }), [])
|
|
107
|
+
const circular = check(); circular.extra = circular
|
|
108
|
+
assert.deepEqual(rowsFor({ checks: [circular] }), [])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a careless-error diagnosis needs a concrete wrong-code quote', () => {
|
|
112
|
+
const rows = rowsFor({ diagnoses: [diagnosis({ category: '不认真', evidence: [{
|
|
113
|
+
quote: 'answer += dp[left][right];', kind: 'missing', why: '附近缺少边界检查',
|
|
114
|
+
}] })] })
|
|
115
|
+
assert.deepEqual(rows, [])
|
|
116
|
+
assert.equal(rowsFor({ diagnoses: [diagnosis({ category: '不认真' })] }).length, 1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('canonical object field order does not change record ids or fingerprints', () => {
|
|
120
|
+
const input = progress({ checks: [check()], diagnoses: [diagnosis()], passes: [practice()] })
|
|
121
|
+
assert.deepEqual(collectEvidence(input), collectEvidence(reverseKeys(input)))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('appending an identical check remains a distinct event without renaming prior refs', () => {
|
|
125
|
+
const item = check()
|
|
126
|
+
const one = rowsFor({ checks: [item] })
|
|
127
|
+
const two = rowsFor({ checks: [item, structuredClone(item)] })
|
|
128
|
+
assert.equal(one.length, 1); assert.equal(two.length, 2)
|
|
129
|
+
assert.equal(two[0].id, one[0].id)
|
|
130
|
+
assert.notEqual(two[0].id, two[1].id)
|
|
131
|
+
assert.equal(two[0].fingerprint, two[1].fingerprint)
|
|
132
|
+
assert.equal(two[0].eventId, one[0].eventId)
|
|
133
|
+
assert.notEqual(two[0].eventId, two[1].eventId)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('editing an existing check or diagnosis changes its ref but does not create a new event', () => {
|
|
137
|
+
const before = rowsFor({ checks: [check()], diagnoses: [diagnosis()] })
|
|
138
|
+
const after = rowsFor({ checks: [check('passed')], diagnoses: [diagnosis({ summary: '复查后确认是空区间状态初始化错误' })] })
|
|
139
|
+
assert.equal(before.length, 2); assert.equal(after.length, 2)
|
|
140
|
+
for (let index = 0; index < before.length; index++) {
|
|
141
|
+
assert.notEqual(after[index].id, before[index].id)
|
|
142
|
+
assert.notEqual(after[index].fingerprint, before[index].fingerprint)
|
|
143
|
+
assert.equal(after[index].eventId, before[index].eventId)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('editing only a practice note invalidates the ref while preserving the training event', () => {
|
|
148
|
+
const before = rowsFor({ passes: [practice({ note: '自己尝试了边界样例' })] })
|
|
149
|
+
const after = rowsFor({ passes: [practice({ note: '补充记录:边界样例仍然没有通过' })] })
|
|
150
|
+
assert.equal(before.length, 1); assert.equal(after.length, 1)
|
|
151
|
+
assert.notEqual(after[0].id, before[0].id)
|
|
152
|
+
assert.notEqual(after[0].fingerprint, before[0].fingerprint)
|
|
153
|
+
assert.equal(after[0].eventId, before[0].eventId)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('overwriting a practice fact invalidates its old evidence ref', () => {
|
|
157
|
+
const before = rowsFor({ passes: [practice()] })
|
|
158
|
+
const after = rowsFor({ passes: [practice({ solved: true, independent: true, minutes: 25 })] })
|
|
159
|
+
assert.equal(before.length, 1); assert.equal(after.length, 1)
|
|
160
|
+
assert.notEqual(after[0].id, before[0].id)
|
|
161
|
+
assert.notEqual(after[0].fingerprint, before[0].fingerprint)
|
|
162
|
+
assert.ok(!after.some((row) => row.id === before[0].id))
|
|
163
|
+
assert.equal(after[0].signal, 'baseline')
|
|
164
|
+
assert.equal(after[0].eventId, before[0].eventId)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('mirrored SCHEDULE actual and PROGRESS practice are deduplicated in favor of practice', () => {
|
|
168
|
+
const fact = practice({ solved: true, independent: false, minutes: 55, note: '提示后完成' })
|
|
169
|
+
const input = progress({ passes: [fact] })
|
|
170
|
+
const schedule = { days: { [date]: { actual: [{ node: '区间 DP', problemId: fact.problemId,
|
|
171
|
+
actualMin: fact.minutes, solved: fact.solved, independent: fact.independent, note: fact.note }] } } }
|
|
172
|
+
const practiceOnly = collectEvidence(input)
|
|
173
|
+
const both = collectEvidence(input, schedule)
|
|
174
|
+
assert.deepEqual(both, practiceOnly)
|
|
175
|
+
assert.equal(both[0].source, 'practice')
|
|
176
|
+
const scheduleOnly = collectEvidence({}, schedule)
|
|
177
|
+
assert.equal(scheduleOnly.length, 1); assert.equal(scheduleOnly[0].source, 'schedule')
|
|
178
|
+
assert.equal(scheduleOnly[0].signal, 'gap')
|
|
179
|
+
assert.equal(scheduleOnly[0].eventId, practiceOnly[0].eventId)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('distinct same-day practice problems and distinct nodes do not collapse', () => {
|
|
183
|
+
const input = { nodes: { A: { passes: [practice({ problemId: '1A' }), practice({ problemId: '1B' })] },
|
|
184
|
+
B: { passes: [practice({ problemId: '1A' })] } } }
|
|
185
|
+
const rows = collectEvidence(input)
|
|
186
|
+
assert.equal(rows.length, 3); assert.equal(new Set(rows.map((row) => row.id)).size, 3)
|
|
187
|
+
assert.equal(new Set(rows.map((row) => row.eventId)).size, 3)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('evidence collection leaves deeply frozen source records untouched', () => {
|
|
191
|
+
const input = deepFreeze(progress({ checks: [check()], diagnoses: [diagnosis()], passes: [practice()] }))
|
|
192
|
+
const schedule = deepFreeze({ days: { [date]: { actual: [{ node: 'A', actualMin: 25,
|
|
193
|
+
solved: true, independent: true, problemId: '9A' }] } } })
|
|
194
|
+
const snapshot = JSON.stringify({ input, schedule })
|
|
195
|
+
assert.equal(collectEvidence(input, schedule).length, 4)
|
|
196
|
+
assert.equal(JSON.stringify({ input, schedule }), snapshot)
|
|
197
|
+
})
|