Initial commit: Learning Lab Express
Includes fixes found while reviewing the app: - Results: open each result by a type+agent+id key, so a video and a material draft with the same id no longer open each other's popup - AuthGate: clear the query cache during render, before pages build their queries, and remount pages on organization switch (pages no longer stay on skeletons for up to a minute after sign-in or refresh) - Instruction: keep only failed rules after a partial approve, so retrying does not create the successful schedules twice; drop the monthly note once count or frequency is edited - Parser/schedules: never schedule a count below one - Audio: ignore the end event of a cancelled utterance when changing speed - Downloads: revoke the object URL later so Safari/Firefox can read it - Results: refetch SCORM content when any part's revision changes; show a failed video as failed instead of "still rendering" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
88
src/express/schedules.test.ts
Normal file
88
src/express/schedules.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentRule, ResearchRule } from './parse'
|
||||
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
||||
|
||||
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding'): ContentRule =>
|
||||
({ type: 'content', kind, count, per, topic })
|
||||
|
||||
// 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,
|
||||
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('ruleOf', () => {
|
||||
it.each([
|
||||
rule('summary', 3),
|
||||
rule('audio', 2),
|
||||
rule('scorm', 1),
|
||||
rule('quiz', 1, 'day'),
|
||||
rule('video', 1, 'week'),
|
||||
rule('video', 1, 'day'),
|
||||
])('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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user