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,54 @@
import { describe, expect, it } from 'vitest'
import { correctIndex, questionsOf, toMarkdown, type Material } from './results'
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
describe('correctIndex', () => {
const options = ['Listen first', 'Interrupt', 'Leave']
it('finds the answer as option text, letter or index', () => {
expect(correctIndex(options, 'listen first')).toBe(0)
expect(correctIndex(options, 'B')).toBe(1)
expect(correctIndex(options, 'C. Leave')).toBe(2)
expect(correctIndex(options, '1')).toBe(1)
})
it('returns -1 for an answer outside the options', () => {
expect(correctIndex(options, 'D')).toBe(-1)
expect(correctIndex([], 'An essay answer')).toBe(-1)
})
})
describe('questionsOf', () => {
it('reads the engine’s question shapes', () => {
const [q] = questionsOf({ questions: [{ text: 'Q', choices: [{ text: 'x' }, { label: 'y' }], correct_answer: 'y', explanation: 'because' }] })
expect(q).toEqual({ question: 'Q', options: ['x', 'y'], answer: 'y', explanation: 'because', correct: 1 })
})
})
describe('toMarkdown', () => {
const material = (kind: Material['kind']): Material => ({
id: 1, agentId: 1, kind, status: 'ready', revision: 1, parts: [], planTitle: 'Onboarding', composeSetId: null,
sources: ['Handbook'], createdAt: '2026-10-01T00:00:00Z', rule: { type: 'content', kind, count: 1, per: 'week', topic: '' },
})
it('writes a SCORM module as the lesson followed by its quiz', () => {
const md = toMarkdown(material('scorm'), { title: 'Onboarding', text: 'Welcome.', questions: [{ question: 'Q?', options: ['a', 'b'], answer: 'b' }] })
expect(md).toMatch(/^# Onboarding\n\nWelcome\.\n\n## Quiz\n\n1\. Q\?\n {3}- a\n {3}- b\n/)
expect(md).toContain('Sources: Handbook')
})
it('writes an audio lesson as its script', () => {
expect(toMarkdown(material('audio'), { text: 'Hello team.' })).toBe('# Onboarding\n\nHello team.\n\n---\nSources: Handbook\n')
})
})
describe('audio script helpers', () => {
it('splits a script into sentences without Markdown', () => {
expect(sentencesOf('# Intro\nHello **team**. Ready? Let’s go!\n\n- one')).toEqual(['Intro', 'Hello team.', 'Ready?', 'Let’s go!', 'one'])
})
it('guesses the script language for the voice', () => {
expect(scriptLang('Ini adalah pelajaran yang penting untuk kita semua dan tim.')).toBe('id')
expect(scriptLang('This is the lesson that we will share with the team.')).toBe('en')
})
})