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:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

View 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)
})
})