Files
learning-lab-express/src/express/schedules.test.ts
Iqbal a73a5a3fdf Require two materials for content schedules and scope readiness copy
The engine plans each production piece from a pair of documents, so a
summary, quiz or similar schedule with one material can never plan. Approving
such a plan is now blocked with a note to choose or upload another material,
and the engine's refusal on an existing schedule is explained in plain
language. Video and research schedules still work with one material.

The engine now checks only a schedule's own material before a run, so the
run error and Upload texts no longer say the whole Library or Brain is
checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:37:56 +07:00

125 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
({ type: 'content', kind, count, per, topic, language })
// What the engine sends back for an agent created from configFor.
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
const c = config as Record<string, unknown>
return {
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false,
name: c.name as string, goal: c.goal as string, instructions: c.instructions as string,
enabled_tools: c.enabled_tools as string[],
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null,
video_options: (c.video_options as ExpressAgent['video_options']) ?? null,
run_schedule: (c.run_schedule as ExpressAgent['run_schedule']) ?? null,
run_budget: c.run_budget as number, monthly_budget: c.monthly_budget as number,
}
}
describe('configFor', () => {
it('makes an audio schedule a narration summary', () => {
const config = configFor(rule('audio')) as Record<string, unknown>
expect(config.material_outputs).toEqual([{ kind: 'summary', options: {} }])
expect(config.instructions).toMatch(/narration script/)
expect(config.name).toBe('Express · 2 audio per week · onboarding')
})
it('makes a SCORM schedule a lesson + quiz pair', () => {
const config = configFor(rule('scorm')) as Record<string, unknown>
expect((config.material_outputs as { kind: string }[]).map(o => o.kind)).toEqual(['summary', 'quiz'])
})
it('makes a video schedule a run-scheduled Video Studio agent', () => {
const config = configFor(rule('video', 1, 'day'), 100) as Record<string, unknown>
expect(config.enabled_tools).toContain('studio.create_video')
expect(config).not.toHaveProperty('content_goal')
expect(config.run_schedule).toMatchObject({ cadence: 'daily' })
expect(config.video_options).toMatchObject({ max_scenes: VIDEO.scenes, max_duration_seconds: VIDEO.seconds })
expect(config.run_budget).toBe(110)
expect(config.monthly_budget).toBe(110 * 31)
})
it('keeps budgets ordered run ≤ weekly ≤ monthly', () => {
for (const kind of ['summary', 'quiz', 'audio', 'scorm'] as const) {
for (const count of [1, 5, 50]) {
const b = budgetFor(rule(kind, count))
expect(b.run).toBeLessThanOrEqual(b.weekly!)
expect(b.weekly!).toBeLessThanOrEqual(b.monthly)
}
}
})
})
describe('content language', () => {
it('writes the chosen language into the brief and the video options', () => {
expect((configFor(rule('summary', 2, 'week', 'sales', '中文')) as { instructions: string }).instructions)
.toMatch(/Write all content in Simplified Chinese \(中文\)\./)
expect((configFor(rule('video', 1, 'week', 'sales', 'Bahasa Indonesia'), 50) as { video_options: { language: string } }).video_options.language)
.toBe('Bahasa Indonesia')
})
})
describe('chosen material', () => {
it('marks a schedule made from chosen material in its brief, and reads it back as chosen', () => {
const r = rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia')
const config = configFor(r, 0, true)
expect(config.instructions).toMatch(/Use only the Brain material chosen for this schedule\./)
expect(configFor(r).instructions).not.toMatch(/chosen/)
expect(ruleOf(agentFrom(config))).toEqual({ ...r, chosen: true })
expect(ruleOf(agentFrom(configFor(r)))).toEqual(r)
})
})
describe('ruleOf', () => {
it.each([
rule('summary', 3),
rule('audio', 2),
rule('scorm', 1),
rule('quiz', 1, 'day'),
rule('video', 1, 'week'),
rule('video', 1, 'day'),
rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia'),
rule('video', 1, 'week', 'sales', '中文'),
])('reads back the rule a schedule was created from: $count $kind per $per', r => {
expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r)
})
it('reads back a research rule', () => {
const r: ResearchRule = { type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: true }
const agent = { ...agentFrom(configFor(r)), auto_approve_sources: true }
expect(ruleOf(agent)).toEqual(r)
})
})
describe('canSchedule', () => {
it('accepts every kind within limits', () => {
expect(canSchedule(rule('audio', 5))).toBe(true)
expect(canSchedule(rule('scorm', 2))).toBe(true)
expect(canSchedule(rule('video', 1))).toBe(true)
})
it('rejects video counts without a cadence and production over 50 a week', () => {
expect(canSchedule(rule('video', 3))).toBe(false)
expect(canSchedule(rule('summary', 8, 'day'))).toBe(false)
expect(canSchedule(rule('summary', 0))).toBe(false)
})
})
describe('production sources', () => {
it('needs two sources for planned content, not for video or research', () => {
expect(needsTwoSources(rule('quiz'))).toBe(true)
expect(needsTwoSources(rule('video', 1))).toBe(false)
expect(needsTwoSources({ type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: false })).toBe(false)
})
it("explains the engine's refusal for too few sources and passes others through", () => {
expect(productionError('At least two ready sources with complementary evidence are required')).toMatch(/2/)
expect(productionError('At least two ready sources with complementary evidence are required')).not.toMatch(/complementary/)
expect(productionError('Something else')).toBe('Something else')
})
})