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.
@@ -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
+ })
@@ -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)