acmer-coach 2.5.0 → 2.6.1
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 +17 -1
- package/assets/knowledge/CONTEST_PROFILES.yaml +173 -47
- package/assets/knowledge/skilltree.html +3 -3
- package/assets/rules/coach-rules.md +10 -0
- package/coach/skilltree_template.html +3 -3
- package/dev/catalog-check.mjs +72 -1
- package/dev/curriculum-evidence-check.mjs +18 -0
- package/dev/grading-check.mjs +161 -0
- package/dev/parity-check.mjs +5 -0
- package/dev/run-all.sh +2 -0
- package/dev/training-check.mjs +183 -0
- package/dev.sh +2 -2
- package/docs/china-contest-sources.md +423 -0
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +26 -3
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +1 -1
- package/index.js +214 -134
- package/lib/curriculum-evidence.js +7 -3
- package/lib/curriculum.js +10 -7
- package/lib/strategy.js +10 -3
- package/package.json +2 -2
- package/verify.mjs +47 -47
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { parse, stringify } from 'yaml'
|
|
7
|
+
import { collectEvidence } from '../lib/curriculum-evidence.js'
|
|
8
|
+
import { evaluateExitChecks } from '../lib/curriculum-planning.js'
|
|
9
|
+
|
|
10
|
+
async function fixture(run, nodeRecord = { status: 'studying' }) {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-grading-'))
|
|
12
|
+
const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
13
|
+
try {
|
|
14
|
+
process.env.COACH_DATA_DIR = dir
|
|
15
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
16
|
+
writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n')
|
|
17
|
+
const path = join(dir, 'PROGRESS.yaml')
|
|
18
|
+
writeFileSync(path, stringify({ version: 1, cursor: 'A', nodes: { A: nodeRecord }, pending: {
|
|
19
|
+
node: 'A', totalMinutes: 90, passScore: 4, maxScore: 6,
|
|
20
|
+
problems: [
|
|
21
|
+
{ problemId: 'P1', band: '易', weight: 1, cfRating: 1200, source: 'pool' },
|
|
22
|
+
{ problemId: 'P2', band: '中', weight: 2, cfRating: 1600, source: 'pool' },
|
|
23
|
+
{ problemId: 'P3', band: '难', weight: 3, cfRating: 1900, source: 'pool' },
|
|
24
|
+
],
|
|
25
|
+
} }))
|
|
26
|
+
const { apply } = await import(`../index.js?grading=${encodeURIComponent(dir)}`)
|
|
27
|
+
const tools = new Map()
|
|
28
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
|
|
29
|
+
await run({ grade: tools.get('coach_grade'), call: (name, args) => tools.get(name).execute(args),
|
|
30
|
+
ledger: () => parse(readFileSync(path, 'utf8')), bytes: () => readFileSync(path, 'utf8') })
|
|
31
|
+
} finally {
|
|
32
|
+
for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
|
|
33
|
+
if (value === undefined) delete process.env[key]
|
|
34
|
+
else process.env[key] = value
|
|
35
|
+
}
|
|
36
|
+
rmSync(dir, { recursive: true, force: true })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const results = (extra = {}) => ['P1', 'P2', 'P3'].map(problemId => ({ problemId, solved: true, minutes: 15, ...extra }))
|
|
41
|
+
const independentExit = ledger => evaluateExitChecks({ exitChecks: [{ node: 'A', minEvents: 1, independent: true }] },
|
|
42
|
+
collectEvidence(ledger), [], '2000-01-01')[0]
|
|
43
|
+
|
|
44
|
+
test('a passing paper without independence facts cannot create a mastery certificate', async () => {
|
|
45
|
+
await fixture(async ({ grade, ledger }) => {
|
|
46
|
+
const result = await grade.execute({ results: results() })
|
|
47
|
+
assert.equal(result.ok, true)
|
|
48
|
+
assert.equal(result.outcome, 'passed')
|
|
49
|
+
assert.equal(result.status, 'studying')
|
|
50
|
+
assert.equal(result.verified, false)
|
|
51
|
+
assert.equal(result.independence, 'unknown')
|
|
52
|
+
assert.equal(result.verifiedAt, 0)
|
|
53
|
+
const record = ledger().nodes.A
|
|
54
|
+
assert.equal(record.checks[0].independent, undefined)
|
|
55
|
+
assert.equal(record.checks[0].verifiedAt, 0)
|
|
56
|
+
assert.equal(independentExit(ledger()).passed, false, 'unknown independence cannot satisfy the course exit condition')
|
|
57
|
+
assert.equal(ledger().pending, undefined)
|
|
58
|
+
assert.match(grade.output.render({}, result)[0].text, /独立性.*未|未.*独立/)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a passing paper with a helped solution stays a paper pass without a certificate', async () => {
|
|
63
|
+
await fixture(async ({ grade, ledger }) => {
|
|
64
|
+
const reported = results({ independent: true })
|
|
65
|
+
reported[1].independent = false
|
|
66
|
+
const result = await grade.execute({ results: reported })
|
|
67
|
+
assert.equal(result.outcome, 'passed')
|
|
68
|
+
assert.equal(result.independence, 'assisted')
|
|
69
|
+
assert.equal(result.verified, false)
|
|
70
|
+
assert.equal(result.status, 'studying')
|
|
71
|
+
assert.equal(result.verifiedAt, 0)
|
|
72
|
+
assert.equal(ledger().nodes.A.checks[0].independent, false)
|
|
73
|
+
assert.equal(independentExit(ledger()).passed, false)
|
|
74
|
+
assert.match(grade.output.render({}, result)[0].text, /有帮助/)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('explicitly independent solved problems certify a passing paper and carry the evidence into course checks', async () => {
|
|
79
|
+
await fixture(async ({ grade, ledger }) => {
|
|
80
|
+
const result = await grade.execute({ results: results({ independent: true }) })
|
|
81
|
+
assert.equal(result.verified, true)
|
|
82
|
+
assert.equal(result.status, 'verified')
|
|
83
|
+
assert.equal(result.independence, 'independent')
|
|
84
|
+
assert.equal(result.verifiedAt, 1900)
|
|
85
|
+
assert.equal(ledger().nodes.A.checks[0].independent, true)
|
|
86
|
+
assert.equal(independentExit(ledger()).passed, true)
|
|
87
|
+
assert.equal(ledger().nodes.A.passes, undefined, 'exam records must not invent practice records')
|
|
88
|
+
assert.match(grade.output.render({}, result)[0].text, /不代表隔日.*迁移/)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('weighted passing still requires explicit independence for every solved problem', async () => {
|
|
93
|
+
await fixture(async ({ grade }) => {
|
|
94
|
+
const reported = results({ independent: true })
|
|
95
|
+
delete reported[2].independent
|
|
96
|
+
const result = await grade.execute({ results: reported })
|
|
97
|
+
assert.equal(result.outcome, 'passed')
|
|
98
|
+
assert.equal(result.verified, false)
|
|
99
|
+
assert.equal(result.independence, 'unknown')
|
|
100
|
+
})
|
|
101
|
+
await fixture(async ({ grade }) => {
|
|
102
|
+
const reported = results({ independent: true })
|
|
103
|
+
reported[1] = { problemId: 'P2', solved: false, minutes: 15 }
|
|
104
|
+
const result = await grade.execute({ results: reported })
|
|
105
|
+
assert.equal(result.score, 4)
|
|
106
|
+
assert.equal(result.verified, true, 'the existing weighted pass line remains valid; unsolved problems do not prove mastery')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('explicit independence never certifies a failing or overtime paper', async () => {
|
|
111
|
+
for (const [reported, outcome] of [
|
|
112
|
+
[results({ independent: true, solved: false }), 'failed'],
|
|
113
|
+
[results({ independent: true, minutes: 40 }), 'overTime'],
|
|
114
|
+
]) await fixture(async ({ grade, ledger }) => {
|
|
115
|
+
const result = await grade.execute({ results: reported })
|
|
116
|
+
assert.equal(result.outcome, outcome)
|
|
117
|
+
assert.equal(result.verified, false)
|
|
118
|
+
assert.equal(result.status, 'studying')
|
|
119
|
+
assert.equal(result.verifiedAt, 0)
|
|
120
|
+
assert.equal(independentExit(ledger()).passed, false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('unknown or assisted passing records preserve historical verified and learned states', async () => {
|
|
125
|
+
const previousCheck = { date: '2026-09-01', outcome: 'passed', score: 6, maxScore: 6, passScore: 4,
|
|
126
|
+
minutes: 45, limit: 90, verifiedAt: 1600, selfReported: 0, problems: [] }
|
|
127
|
+
for (const status of ['verified', 'learned']) await fixture(async ({ grade, ledger }) => {
|
|
128
|
+
const result = await grade.execute({ results: results() })
|
|
129
|
+
assert.equal(result.status, status)
|
|
130
|
+
assert.equal(result.verified, false, 'status may be historical; verified describes this attempt only')
|
|
131
|
+
assert.equal(result.verifiedAt, 0)
|
|
132
|
+
assert.equal(ledger().nodes.A.at, '2026-09-01')
|
|
133
|
+
assert.deepEqual(ledger().nodes.A.checks[0], previousCheck)
|
|
134
|
+
assert.equal(ledger().nodes.A.checks.length, 2)
|
|
135
|
+
}, { status, at: '2026-09-01', checks: [previousCheck] })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('malformed grading fields and contradictory IDs are rejected without changing the pending paper', async () => {
|
|
139
|
+
await fixture(async ({ grade, bytes }) => {
|
|
140
|
+
const before = bytes()
|
|
141
|
+
for (const invalid of [
|
|
142
|
+
{ independent: 'true' }, { independent: null }, { solved: 'true' },
|
|
143
|
+
{ minutes: -1 }, { minutes: Infinity }, { minutes: Number.MAX_VALUE }, { minutes: '15' }, { problemId: '' }, { problemId: 'P2' },
|
|
144
|
+
]) {
|
|
145
|
+
const reported = results()
|
|
146
|
+
Object.assign(reported[0], invalid)
|
|
147
|
+
let rejected
|
|
148
|
+
try { rejected = await grade.execute({ results: reported }) }
|
|
149
|
+
catch (error) {
|
|
150
|
+
assert.equal(error.code, 'INVALID_ARGS', 'the native SDK may reject malformed types before tool execution')
|
|
151
|
+
}
|
|
152
|
+
if (rejected) {
|
|
153
|
+
assert.equal(rejected.ok, false, JSON.stringify(invalid))
|
|
154
|
+
assert.equal(rejected.verified, false)
|
|
155
|
+
assert.equal(rejected.independence, 'unknown')
|
|
156
|
+
for (const key of grade.output.schema.required) assert.ok(key in rejected, `missing refusal field ${key}`)
|
|
157
|
+
}
|
|
158
|
+
assert.equal(bytes(), before)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
})
|
package/dev/parity-check.mjs
CHANGED
|
@@ -135,6 +135,10 @@ for (const [tool, args, label] of CALLS) {
|
|
|
135
135
|
delete ra.targetAligned; delete ra.competencies
|
|
136
136
|
}
|
|
137
137
|
// A legacy host cannot return the new empty continuation field.
|
|
138
|
+
if (tool === 'coach_schedule' && Array.isArray(ra?.blockKinds) && Array.isArray(rb?.blockKinds) && rb.blockKinds.length === 3) {
|
|
139
|
+
const added = ra.blockKinds.slice(3)
|
|
140
|
+
if (JSON.stringify(added) === JSON.stringify([{ kind: 'diagnostic', minutes: 15, name: '短诊断' }, { kind: 'vp', minutes: 300, name: '完整 VP' }])) ra.blockKinds = ra.blockKinds.slice(0, 3)
|
|
141
|
+
}
|
|
138
142
|
if (tool === 'coach_status' && rb && !('teachingContext' in rb) && ra?.teachingContext === '') {
|
|
139
143
|
delete ra.teachingContext
|
|
140
144
|
}
|
|
@@ -165,6 +169,7 @@ for (const [tool, args, label] of CALLS.filter((c) => c[0] !== 'coach_diagnose')
|
|
|
165
169
|
: legacyText)
|
|
166
170
|
// Only the changed goal-led advice is excluded; schedule facts still match byte for byte.
|
|
167
171
|
const scheduleAdvice = (text) => text
|
|
172
|
+
.replace('/diagnostic 15min(短诊断)/vp 300min(完整 VP)', '')
|
|
168
173
|
.replace(/^⚠️ \*\*复习别把新知识挤没了。\*\*.*剩下的时间留给推进。$/m, '<COACH_ADVICE>')
|
|
169
174
|
.replace(/^根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。上面的间隔只供判断;不要为了填满课表持续开新知识点。$/m, '<COACH_ADVICE>')
|
|
170
175
|
const same = tool === 'coach_schedule' ? scheduleAdvice(ra) === scheduleAdvice(rb) : ra === rb
|
package/dev/run-all.sh
CHANGED
|
@@ -69,6 +69,8 @@ run "strategy" node --test dev/strategy-check.mjs
|
|
|
69
69
|
run "catalog" node --test dev/catalog-check.mjs
|
|
70
70
|
run "coach-contract" node --test dev/coaching-contract-check.mjs
|
|
71
71
|
run "teaching" node --test dev/teaching-check.mjs
|
|
72
|
+
run "grading" node --test dev/grading-check.mjs
|
|
73
|
+
run "training" node --test dev/training-check.mjs
|
|
72
74
|
run "VP-client" node dev/vp-client-check.mjs
|
|
73
75
|
run "VP-sync" node --test dev/vp-sync-check.mjs
|
|
74
76
|
run "migration" node --test dev/workspace-migration-check.mjs
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { parse, stringify } from 'yaml'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import { syncBuiltinESMExports } from 'node:module'
|
|
9
|
+
|
|
10
|
+
async function fixture(run, { cursor = 'A' } = {}) {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'coach-training-'))
|
|
12
|
+
const old = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
|
|
13
|
+
writeFileSync(join(dir, 'MAP.yaml'), stringify({ meta: { version: 1 }, nodes: [{ id: 'A', name: '基础', depends: [] }] }))
|
|
14
|
+
writeFileSync(join(dir, 'PROGRESS.yaml'), stringify({ version: 1, cursor, nodes: { A: { status: 'studying' } } }))
|
|
15
|
+
process.env.COACH_DATA_DIR = dir
|
|
16
|
+
process.env.COACH_DB = join(dir, 'unused.db')
|
|
17
|
+
try {
|
|
18
|
+
const { apply } = await import(`../index.js?training-${Date.now()}-${Math.random()}`)
|
|
19
|
+
const tools = new Map()
|
|
20
|
+
apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
|
|
21
|
+
const call = async (name, args) => {
|
|
22
|
+
const tool = tools.get(name)
|
|
23
|
+
const value = await tool.execute(args)
|
|
24
|
+
shape(tool.output.schema, value)
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
await run({ call, tools, dir, read: file => parse(readFileSync(join(dir, file), 'utf8')), write: (file, doc) => writeFileSync(join(dir, file), stringify(doc)) })
|
|
28
|
+
} finally {
|
|
29
|
+
for (const [key, value] of [['COACH_DATA_DIR', old.data], ['COACH_DB', old.db]]) {
|
|
30
|
+
if (value === undefined) delete process.env[key]
|
|
31
|
+
else process.env[key] = value
|
|
32
|
+
}
|
|
33
|
+
rmSync(dir, { recursive: true, force: true })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('a failed progress write cannot report the pending action as cleared', async () => fixture(async ({ call, read }) => {
|
|
38
|
+
assert.equal((await call('coach_assign', { competency: 'contest-reading', kind: 'diagnostic', minutes: 15, deliverable: '说明三题的模型条件' })).accepted, true)
|
|
39
|
+
const originalWrite = fs.writeFileSync
|
|
40
|
+
let result
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync = (path, ...args) => {
|
|
43
|
+
if (String(path).includes('PROGRESS.yaml')) throw new Error('simulated disk write failure')
|
|
44
|
+
return originalWrite(path, ...args)
|
|
45
|
+
}
|
|
46
|
+
syncBuiltinESMExports()
|
|
47
|
+
result = await call('coach_log', { competency: 'contest-reading', actualMin: 15, solved: true, independent: true })
|
|
48
|
+
} finally { fs.writeFileSync = originalWrite; syncBuiltinESMExports() }
|
|
49
|
+
assert.equal(result.recorded, false)
|
|
50
|
+
assert.equal(result.actionCleared, false)
|
|
51
|
+
assert.ok(read('PROGRESS.yaml').pendingActions['competency:contest-reading'])
|
|
52
|
+
assert.equal(read('SCHEDULE.yaml').days[result.date].actual.length, 1, 'the successfully saved actual remains visible')
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
function shape(schema, value) {
|
|
56
|
+
if (schema.type === 'object') {
|
|
57
|
+
assert.ok(value && typeof value === 'object' && !Array.isArray(value))
|
|
58
|
+
if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
|
|
59
|
+
for (const [key, field] of Object.entries(schema.properties ?? {})) {
|
|
60
|
+
if (field.required === true || schema.required?.includes(key)) assert.ok(key in value, `missing ${key}`)
|
|
61
|
+
if (key in value) shape(field, value[key])
|
|
62
|
+
}
|
|
63
|
+
} else if (schema.type === 'array') {
|
|
64
|
+
assert.ok(Array.isArray(value)); value.forEach(item => shape(schema.items, item))
|
|
65
|
+
} else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
|
|
66
|
+
else assert.equal(typeof value, schema.type)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('15-minute node diagnostic fits one action without pretending a lecture or mastery happened', async () => fixture(async ({ call, read }) => {
|
|
70
|
+
const assigned = await call('coach_assign', { node: 'A', kind: 'diagnostic', minutes: 15, deliverable: '说出状态定义并解释为什么保留这些信息' })
|
|
71
|
+
assert.equal(assigned.accepted, true, assigned.reject)
|
|
72
|
+
assert.equal(assigned.totalMinutes, 15)
|
|
73
|
+
assert.deepEqual(assigned.blocks.map(block => block.minutes), [15])
|
|
74
|
+
const progress = read('PROGRESS.yaml')
|
|
75
|
+
assert.equal(progress.nodes.A.status, 'studying')
|
|
76
|
+
assert.equal(progress.nodes.A.taught, undefined)
|
|
77
|
+
assert.equal(progress.pendingActions.A.kind, 'diagnostic')
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
test('a full 300-minute VP occupies one schedule block within the physical day', async () => fixture(async ({ call }) => {
|
|
81
|
+
const now = new Date()
|
|
82
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
83
|
+
const planned = await call('coach_plan', { date, blocks: [{ from: '10:00', node: 'A', kind: 'vp', minutes: 300 }] })
|
|
84
|
+
assert.equal(planned.ok, true, planned.reject)
|
|
85
|
+
assert.equal(planned.planned.length, 1)
|
|
86
|
+
assert.equal(planned.planned[0].to, '15:00')
|
|
87
|
+
assert.equal(planned.planMin, 300)
|
|
88
|
+
assert.equal((await call('coach_plan', { date, replace: true, blocks: [{ from: '20:00', node: 'A', kind: 'vp', minutes: 300 }] })).ok, false)
|
|
89
|
+
const schedule = await call('coach_schedule', { from: date, days: 1 })
|
|
90
|
+
assert.equal(schedule.days[0].planned[0].minutes, 300)
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
test('competency diagnostic works without a cursor and completes through coach_log without a fake map node', async () => fixture(async ({ call, read }) => {
|
|
94
|
+
const assigned = await call('coach_assign', { competency: 'contest-reading', kind: 'diagnostic', minutes: 15, deliverable: '扫描题面,提交题目筛选顺序和放弃理由' })
|
|
95
|
+
assert.equal(assigned.accepted, true, assigned.reject)
|
|
96
|
+
assert.equal(assigned.node, '')
|
|
97
|
+
assert.equal(assigned.competency, 'contest-reading')
|
|
98
|
+
assert.equal(assigned.totalMinutes, 15)
|
|
99
|
+
assert.equal(read('PROGRESS.yaml').pendingActions['competency:contest-reading'].competency, 'contest-reading')
|
|
100
|
+
const status = await call('coach_status', {})
|
|
101
|
+
const pending = status.pendingActions[0]
|
|
102
|
+
assert.equal(pending.node, '')
|
|
103
|
+
assert.equal(pending.competency, 'contest-reading')
|
|
104
|
+
assert.equal(pending.kind, 'diagnostic')
|
|
105
|
+
assert.equal(pending.nodeName, '比赛读题与筛选')
|
|
106
|
+
const logged = await call('coach_log', { competency: 'contest-reading', actualMin: 15, solved: false, independent: false, note: '遗漏了最后一道题的输入限制' })
|
|
107
|
+
assert.equal(logged.ok, true, logged.reject)
|
|
108
|
+
assert.equal(logged.actionCleared, true)
|
|
109
|
+
assert.equal(logged.recorded, true)
|
|
110
|
+
assert.deepEqual(Object.keys(read('PROGRESS.yaml').nodes), ['A'])
|
|
111
|
+
assert.equal(read('SCHEDULE.yaml').days[logged.date].actual[0].competency, 'contest-reading')
|
|
112
|
+
assert.equal(read('PROGRESS.yaml').pendingActions, undefined)
|
|
113
|
+
const assessed = await call('coach_curriculum', { action: 'assess' })
|
|
114
|
+
assert.equal(assessed.evidence.find(entry => entry.competency === 'contest-reading')?.signal, 'gap')
|
|
115
|
+
assert.equal((await call('coach_assign', { competency: 'invented', kind: 'diagnostic', minutes: 15, deliverable: '提交观察' })).accepted, false)
|
|
116
|
+
assert.equal((await call('coach_log', { competency: 'invented', actualMin: 15, solved: true, independent: true })).ok, false)
|
|
117
|
+
}, { cursor: '' }))
|
|
118
|
+
|
|
119
|
+
test('competency actions bind the diagnostic phase, reject stale or other-unit work, and yield usable evidence', async () => fixture(async ({ call, read }) => {
|
|
120
|
+
const phase = { id: 'read-probe', title: '读题诊断', kind: 'diagnostic', finding: 'unknown', nodes: [], competencies: ['contest-reading'],
|
|
121
|
+
outcome: '确认读题筛选遗漏发生在哪个环节', exitCriteria: '记录一次新的读题诊断观察结果',
|
|
122
|
+
basis: '目前没有读题过程的独立观察记录', goalContribution: '识别区域赛首轮漏读与错误筛选问题', priorityReason: '先观察当前筛选过程再决定训练重点', estimatedHours: 1,
|
|
123
|
+
evidenceRefs: [], uncertainty: '目前缺少读题过程记录无法判断原因', diagnosticMethod: '限时扫描完整题面并解释筛选和放弃理由' }
|
|
124
|
+
const created = await call('coach_curriculum', { action: 'create', expectedRevision: 0, goal: '区域赛读题筛选更稳定', reason: '缺少当前读题事实,先安排一次小诊断建立依据', phases: [phase] })
|
|
125
|
+
assert.equal(created.ok, true, created.reject)
|
|
126
|
+
const action = { competency: 'contest-reading', kind: 'diagnostic', minutes: 15, deliverable: '提交筛选顺序和放弃理由', why: '观察是否遗漏输入限制和可做题', curriculumRevision: created.revision }
|
|
127
|
+
assert.equal((await call('coach_assign', { ...action, curriculumRevision: created.revision - 1 })).accepted, false)
|
|
128
|
+
assert.equal((await call('coach_assign', { ...action, competency: 'contest-decision' })).accepted, false)
|
|
129
|
+
const assigned = await call('coach_assign', action)
|
|
130
|
+
assert.equal(assigned.accepted, true, assigned.reject)
|
|
131
|
+
assert.equal(read('PROGRESS.yaml').pendingActions['competency:contest-reading'].curriculum.phaseId, 'read-probe')
|
|
132
|
+
assert.equal((await call('coach_assign', { node: 'A', deliverable: '提交代码', purpose: 'remediation', routeReason: '补充当前读题阶段的实现基础再回到读题诊断', why: '补齐基础', curriculumRevision: created.revision })).accepted, false)
|
|
133
|
+
const logged = await call('coach_log', { competency: 'contest-reading', problemId: 'probe-1', actualMin: 15, solved: false, independent: false })
|
|
134
|
+
assert.equal(logged.actionCleared, true)
|
|
135
|
+
const state = await call('coach_curriculum', { action: 'read' })
|
|
136
|
+
assert.equal(state.pendingNodes.length, 0)
|
|
137
|
+
assert.equal(state.missingFreshEvidence.length, 0)
|
|
138
|
+
assert.equal(state.evidence[0].competency, 'contest-reading')
|
|
139
|
+
}, { cursor: '' }))
|
|
140
|
+
|
|
141
|
+
test('competency VP can be scheduled and withdrawn without fake nodes', async () => fixture(async ({ call }) => {
|
|
142
|
+
const now = new Date()
|
|
143
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
144
|
+
const planned = await call('coach_plan', { date, blocks: [{ from: '10:00', competency: 'contest-reading', kind: 'vp', minutes: 300 }] })
|
|
145
|
+
assert.equal(planned.ok, true, planned.reject)
|
|
146
|
+
assert.equal(planned.planned[0].node, '')
|
|
147
|
+
assert.equal(planned.planned[0].competency, 'contest-reading')
|
|
148
|
+
const schedule = await call('coach_schedule', { from: date, days: 1 })
|
|
149
|
+
assert.equal(schedule.days[0].planned[0].nodeName, '比赛读题与筛选')
|
|
150
|
+
assert.equal(schedule.days[0].planned[0].competency, 'contest-reading')
|
|
151
|
+
assert.equal((await call('coach_assign', { competency: 'contest-reading', kind: 'vp', minutes: 300, deliverable: '完成比赛并提交首轮筛选过程' })).accepted, true)
|
|
152
|
+
const withdrawn = await call('coach_unassign', { competency: 'contest-reading', reason: '队友临时有事,本次合练取消' })
|
|
153
|
+
assert.equal(withdrawn.ok, true, withdrawn.reject)
|
|
154
|
+
assert.equal(withdrawn.node, '')
|
|
155
|
+
assert.equal(withdrawn.competency, 'contest-reading')
|
|
156
|
+
const logged = await call('coach_log', { competency: 'contest-reading', problemId: 'VP-test', actualMin: 300, solved: true, independent: true })
|
|
157
|
+
assert.equal(logged.plannedMin, 300)
|
|
158
|
+
assert.equal((await call('coach_schedule', { from: date, days: 1 })).days[0].actual[0].competency, 'contest-reading')
|
|
159
|
+
assert.equal((await call('coach_unplan', { date, all: true })).removed[0].competency, 'contest-reading')
|
|
160
|
+
}, { cursor: '' }))
|
|
161
|
+
|
|
162
|
+
test('explicit durations preserve defaults and reject invalid lengths, busy collisions and overlap atomically', async () => fixture(async ({ call, read, write }) => {
|
|
163
|
+
const now = new Date()
|
|
164
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
165
|
+
const defaults = await call('coach_plan', { date, blocks: [{ from: '08:00', node: 'A', kind: 'cycle' }, { from: '09:00', node: 'A', kind: 'review' }, { from: '10:00', node: 'A', kind: 'exam' }] })
|
|
166
|
+
assert.equal(defaults.ok, true, defaults.reject)
|
|
167
|
+
assert.deepEqual(defaults.planned.map(block => block.minutes), [60, 25, 90])
|
|
168
|
+
const previous = read('SCHEDULE.yaml')
|
|
169
|
+
for (const minutes of [0, -1, 961]) {
|
|
170
|
+
assert.equal((await call('coach_plan', { date, replace: true, blocks: [{ from: '08:00', node: 'A', kind: 'cycle', minutes }] })).ok, false)
|
|
171
|
+
assert.equal((await call('coach_assign', { node: 'A', kind: 'diagnostic', minutes, deliverable: '提交观察' })).accepted, false)
|
|
172
|
+
}
|
|
173
|
+
for (const minutes of [NaN, Infinity, 1.5]) await assert.rejects(call('coach_plan', { date, replace: true, blocks: [{ from: '08:00', node: 'A', kind: 'cycle', minutes }] }), /invalid arguments/)
|
|
174
|
+
assert.deepEqual(read('SCHEDULE.yaml'), previous)
|
|
175
|
+
assert.equal((await call('coach_assign', { node: 'A', competency: 'contest-reading', deliverable: '提交观察' })).accepted, false)
|
|
176
|
+
assert.equal((await call('coach_assign', { node: 'A', minutes: 15, deliverable: '完成一个很小的迁移题' })).totalMinutes, 35)
|
|
177
|
+
const busy = { version: 1, days: { [date]: { busy: [{ from: '12:00', to: '13:00', label: '上课' }] } } }
|
|
178
|
+
write('SCHEDULE.yaml', busy)
|
|
179
|
+
assert.equal((await call('coach_plan', { date, blocks: [{ from: '10:00', node: 'A', kind: 'vp', minutes: 300 }] })).ok, false)
|
|
180
|
+
assert.deepEqual(read('SCHEDULE.yaml'), busy)
|
|
181
|
+
assert.equal((await call('coach_plan', { date, blocks: [{ from: '08:00', node: 'A', kind: 'diagnostic', minutes: 15 }, { from: '08:10', node: 'A', kind: 'diagnostic', minutes: 15 }] })).ok, false)
|
|
182
|
+
assert.deepEqual(read('SCHEDULE.yaml'), busy)
|
|
183
|
+
}))
|
package/dev.sh
CHANGED
|
@@ -35,7 +35,7 @@ done
|
|
|
35
35
|
# 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
|
|
36
36
|
# 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
|
|
37
37
|
# 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
|
|
38
|
-
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js lib/teaching.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/run-all.sh docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md dev.sh'
|
|
38
|
+
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js lib/teaching.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/knowledge/skilltree.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/grading-check.mjs dev/training-check.mjs coach/skilltree_template.html dev/run-all.sh docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md docs/china-contest-sources.md dev.sh'
|
|
39
39
|
STALE=''
|
|
40
40
|
for f in $SYNC_FILES; do
|
|
41
41
|
for d in "${DSTS[@]}"; do
|
|
@@ -120,7 +120,7 @@ EVIDENCE_RC=${EVIDENCE_RC:-0}
|
|
|
120
120
|
|
|
121
121
|
echo
|
|
122
122
|
echo '── 策略、教学目录、真实工具与账号数据回归 ──'
|
|
123
|
-
node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/teaching-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
|
|
123
|
+
node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/teaching-check.mjs" "$SRC_BASH/dev/grading-check.mjs" "$SRC_BASH/dev/training-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
|
|
124
124
|
CONTRACT_RC=${CONTRACT_RC:-0}
|
|
125
125
|
|
|
126
126
|
LEDGER_AFTER=$(ledger_fp)
|