learningfoundry 0.49.0__tar.gz → 0.52.0__tar.gz
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.
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/CHANGELOG.md +36 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/PKG-INFO +1 -1
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/pyproject.toml +1 -1
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/__init__.py +1 -1
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/README.md +47 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +77 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +51 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +98 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +57 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +4 -1
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +145 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +227 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +93 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/components/TextBlock.svelte +7 -4
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/components/module-list.helpers.ts +6 -2
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/components/module-list.test.ts +4 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +26 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +1 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +107 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/db/progress.ts +27 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/types/index.ts +11 -1
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/utils/locking.ts +4 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/utils/progress.test.ts +10 -0
- learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/vite.config.ts +22 -0
- learningfoundry-0.52.0/sveltekit_template/e2e/README.md +47 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/LessonList.svelte +4 -1
- learningfoundry-0.52.0/sveltekit_template/src/lib/components/LessonView.svelte +145 -0
- learningfoundry-0.52.0/sveltekit_template/src/lib/components/LessonView.test.ts +227 -0
- learningfoundry-0.52.0/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +93 -0
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/TextBlock.svelte +7 -4
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +6 -2
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/module-list.test.ts +4 -0
- learningfoundry-0.52.0/sveltekit_template/src/lib/components/mount.test.ts +26 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/db/index.ts +1 -0
- learningfoundry-0.52.0/sveltekit_template/src/lib/db/progress.test.ts +107 -0
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/db/progress.ts +27 -0
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/types/index.ts +11 -1
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/locking.ts +4 -0
- {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/progress.test.ts +10 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -24
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -27
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -82
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -117
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +0 -44
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/vite.config.ts +0 -15
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/LessonView.svelte +0 -82
- learningfoundry-0.49.0/sveltekit_template/src/lib/db/progress.test.ts +0 -44
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/.gitignore +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/LICENSE +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/README.md +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/asset_resolver.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/cli.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/config.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/generator.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/pipeline.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/resolver.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/schema_v1.py +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/progress.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
- {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/layout.test.ts +0 -0
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.52.0] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Svelte 5 component mount support in vitest** (Story I.q). Component tests can now `render(...)` from `@testing-library/svelte` directly, replacing the source-text and helper-only workarounds used in v0.50.0 and v0.51.0. New [mount.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts) smoke fails loudly if the config silently reverts. [TextBlock.observer.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts) rewritten to mount the real component, stub `IntersectionObserver`, capture the observed element, and assert it is the sentinel with non-zero inline height. One previously-deferred I.p case re-instated in [LessonView.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts): asserts `markLessonOpened` resolves before `onlessonopen` fires (lifecycle ordering contract).
|
|
15
|
+
- `@testing-library/svelte` and `@testing-library/jest-dom` dev dependencies.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `vite.config.ts` adds `resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined`. Vitest pulls Svelte's browser entry so `mount(...)` works in jsdom; production `vite build` is unaffected (the conditions block is gated on the env var). Documented in `project-essentials.md` under a new "Testing" subsection so the guard isn't stripped in a future "simplify".
|
|
20
|
+
|
|
21
|
+
## [0.51.0] - 2026-05-01
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Lesson `opened` status and three lifecycle event hooks** (Story I.p / FR-P15). `LessonStatus` now runs `not_started → opened → in_progress → complete` (plus the orthogonal `optional`). `LessonView` mounts call new `markLessonOpened` DB op (upgrade-only — never demotes a more advanced status), then dispatch `onlessonopen`. `markLessonInProgress` and `onlessonengage` now fire on the *first* block-completion event of the mount session — not on mount itself — so a learner who opens a lesson but engages with no content is distinguishable from one genuinely partway through. `onlessoncomplete` fires after `markLessonComplete` succeeds. Revisits to a `complete` lesson fire `onlessonopen` only (no engage / complete events when no transition occurs); zero-block lessons fire `onlessonopen` then `onlessoncomplete` in order. No internal subscribers exist today — the events are forward-compatible hooks for future analytics / telemetry adapters.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `markLessonInProgress` is now invoked on the first block-engagement event rather than on mount. SQL itself is unchanged.
|
|
30
|
+
- Sidebar icon mapping broadened: `opened` shows the same `…` icon (and `text-blue-500` class) as `in_progress`. The lifecycle distinction is data-only — learners see the same "started" symbol regardless of engagement, by design (FR-P15 / Q2).
|
|
31
|
+
- `getModuleProgress`'s module-status derivation: `opened` falls into the `s !== 'not_started'` branch and surfaces as `in_progress` at the module level (intentional; one-line comment added).
|
|
32
|
+
|
|
33
|
+
## [0.50.0] - 2026-05-01
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Text-block completion regression introduced in v0.48.0.** The end-of-block sentinel was rendered with `height: 0`, causing `IntersectionObserver` to compute `intersectionRatio = 0` against the configured `0.1` threshold and the `isIntersecting` branch to never fire in real browsers. Net effect: lessons were never marked complete (no sidebar `✓`, no module % movement, no curriculum-bar movement), and revisits couldn't pre-fill the Next/Finish enabled state. The sentinel now renders as `<div data-textblock-end style="height: 1px">` — invisible to learners but observable by the browser. The vitest helper-only suite was unchanged by the regression because it never instantiated a real observer; the e2e harness was unchanged because the spec asserted only sentinel presence rather than actual completion.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **TextBlock sentinel anti-regression vitest coverage** ([TextBlock.observer.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts)): three source-template assertions covering the v0.48.0 zero-area trap — sentinel exists with `data-textblock-end`, carries inline `style="height: 1px"`, and is the element passed to `observer.observe()`. Source-text assertions are brittle to formatting but reliable; mounting Svelte 5 components in vitest (via `@testing-library/svelte` or `svelte/server`) collided with the SvelteKit vite plugin's client-mode compilation, so the canonical cross-check that the markup actually behaves is the e2e harness.
|
|
42
|
+
- **Lesson-completion e2e tests** ([progress.spec.ts](src/learningfoundry/sveltekit_template/e2e/progress.spec.ts)): three new cases exercising the FR-P11 user-visible outcome — short-text-block lesson transitions to `✓` in the sidebar without reload; dashboard "X of N completed" increments after completion; revisiting a complete lesson pre-fills Next/Finish as enabled.
|
|
43
|
+
- **Tall-text-block scroll-to-complete e2e tests** ([text-block-bottom.spec.ts](src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts)): rewritten from the prior "structural existence" check. Tall lesson does NOT complete without scroll; scrolling `<main>` to the bottom triggers `✓` within 2 s.
|
|
44
|
+
- **Dedicated e2e curriculum fixture** ([e2e/fixtures/curriculum.json](src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json) + [e2e/README.md](src/learningfoundry/sveltekit_template/e2e/README.md)): self-contained 3-lesson fixture covering short-text completion and tall-text scroll-to-complete. Specs install a `page.route('**/curriculum.json', …)` interception in `beforeEach` so the harness is decoupled from the smoke build's curriculum drift.
|
|
45
|
+
|
|
10
46
|
## [0.49.0] - 2026-05-01
|
|
11
47
|
|
|
12
48
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learningfoundry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.52.0
|
|
4
4
|
Summary: A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application.
|
|
5
5
|
Project-URL: Homepage, https://github.com/pointmatic/learningfoundry
|
|
6
6
|
Project-URL: Repository, https://github.com/pointmatic/learningfoundry
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "learningfoundry"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.52.0"
|
|
8
8
|
description = "A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Playwright e2e tests
|
|
2
|
+
|
|
3
|
+
These tests run against the static `pnpm preview` server (see
|
|
4
|
+
`playwright.config.ts`) and exercise the navigation/completion lifecycle
|
|
5
|
+
that vitest cannot reach because vitest mocks `$app/navigation` and
|
|
6
|
+
`IntersectionObserver`.
|
|
7
|
+
|
|
8
|
+
## Fixture curriculum
|
|
9
|
+
|
|
10
|
+
`fixtures/curriculum.json` is a hand-authored, self-contained curriculum
|
|
11
|
+
matching the runtime shape that `learningfoundry build` emits. The
|
|
12
|
+
specs install a `page.route('**/curriculum.json', …)` interception in
|
|
13
|
+
`beforeEach` so the app loads the fixture instead of whatever
|
|
14
|
+
`curriculum.json` happens to live in `static/` from the smoke build —
|
|
15
|
+
that decoupling keeps these tests stable against unrelated curriculum
|
|
16
|
+
edits.
|
|
17
|
+
|
|
18
|
+
The fixture contains three lessons:
|
|
19
|
+
|
|
20
|
+
- `mod-01/lesson-01` — short text block; sentinel fits in the
|
|
21
|
+
viewport on first render and `textcomplete` fires after 1 s.
|
|
22
|
+
- `mod-01/lesson-02` — tall text block (`200vh` spacer) where the
|
|
23
|
+
sentinel only intersects after `<main>` scrolls to the bottom;
|
|
24
|
+
completion must not fire before scroll and must fire within 2 s of
|
|
25
|
+
it.
|
|
26
|
+
- `mod-02/lesson-01` — trailing short lesson, gives navigation
|
|
27
|
+
sequences a destination.
|
|
28
|
+
|
|
29
|
+
## Regenerating the fixture
|
|
30
|
+
|
|
31
|
+
The fixture was hand-authored to keep it tightly scoped to the
|
|
32
|
+
behaviours the specs care about. If you ever want to derive it from
|
|
33
|
+
YAML so it shares the validation path with author-facing curricula,
|
|
34
|
+
write a tiny `learningfoundry build` driver into `e2e/fixtures/yaml/`
|
|
35
|
+
and copy the resulting `curriculum.json` over this file. Until that
|
|
36
|
+
need shows up, hand-editing this file is the path of least surprise.
|
|
37
|
+
|
|
38
|
+
## Running locally
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pnpm exec playwright install chromium # one-time
|
|
42
|
+
pnpm e2e
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The smoke test (`tests/test_smoke_sveltekit.py::test_pnpm_e2e_passes`)
|
|
46
|
+
runs the same suite after `pnpm build` and skips gracefully when the
|
|
47
|
+
Chromium browser is not installed.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"title": "E2E Fixture",
|
|
4
|
+
"description": "Synthetic curriculum used by Playwright e2e tests to exercise lesson completion, sidebar reactivity, and tall-block scroll-to-complete without depending on the smoke build's ever-changing fixture.",
|
|
5
|
+
"locking": { "sequential": false, "lesson_sequential": false },
|
|
6
|
+
"assets": [],
|
|
7
|
+
"modules": [
|
|
8
|
+
{
|
|
9
|
+
"id": "mod-01",
|
|
10
|
+
"title": "Module One",
|
|
11
|
+
"description": "Short-text completion + tall-text scroll.",
|
|
12
|
+
"locked": null,
|
|
13
|
+
"pre_assessment": null,
|
|
14
|
+
"post_assessment": null,
|
|
15
|
+
"lessons": [
|
|
16
|
+
{
|
|
17
|
+
"id": "lesson-01",
|
|
18
|
+
"title": "Short Lesson",
|
|
19
|
+
"unlock_module_on_complete": false,
|
|
20
|
+
"content_blocks": [
|
|
21
|
+
{
|
|
22
|
+
"type": "text",
|
|
23
|
+
"source": null,
|
|
24
|
+
"ref": "content/mod-01/lesson-01.md",
|
|
25
|
+
"content": {
|
|
26
|
+
"markdown": "Hello. This is a short text block whose sentinel sits inside the viewport on first render, so it should fire `textcomplete` after the 1 s debounce.",
|
|
27
|
+
"path": "content/mod-01/lesson-01.md"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "lesson-02",
|
|
34
|
+
"title": "Tall Lesson",
|
|
35
|
+
"unlock_module_on_complete": false,
|
|
36
|
+
"content_blocks": [
|
|
37
|
+
{
|
|
38
|
+
"type": "text",
|
|
39
|
+
"source": null,
|
|
40
|
+
"ref": "content/mod-01/lesson-02.md",
|
|
41
|
+
"content": {
|
|
42
|
+
"markdown": "# Tall block\n\nThis lesson is long enough that the end-of-block sentinel sits below the fold on initial render.\n\n<div style=\"height: 200vh\"> </div>\n\nThe sentinel only intersects the viewport once `<main>` has been scrolled to the bottom — completion must NOT fire before that scroll, and it MUST fire within 2 s of reaching the bottom.",
|
|
43
|
+
"path": "content/mod-01/lesson-02.md"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "mod-02",
|
|
52
|
+
"title": "Module Two",
|
|
53
|
+
"description": "Trailing module so the curriculum has at least three lessons total.",
|
|
54
|
+
"locked": null,
|
|
55
|
+
"pre_assessment": null,
|
|
56
|
+
"post_assessment": null,
|
|
57
|
+
"lessons": [
|
|
58
|
+
{
|
|
59
|
+
"id": "lesson-01",
|
|
60
|
+
"title": "Trailing Short Lesson",
|
|
61
|
+
"unlock_module_on_complete": false,
|
|
62
|
+
"content_blocks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "text",
|
|
65
|
+
"source": null,
|
|
66
|
+
"ref": "content/mod-02/lesson-01.md",
|
|
67
|
+
"content": {
|
|
68
|
+
"markdown": "Trailing lesson body. Provides a destination for navigation tests.",
|
|
69
|
+
"path": "content/mod-02/lesson-01.md"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P15 / Story I.p — lesson lifecycle visual sequence.
|
|
5
|
+
//
|
|
6
|
+
// The data sequence underneath is:
|
|
7
|
+
// not_started → opened (mount) → in_progress (first block engage) → complete
|
|
8
|
+
// The visual sequence the learner sees is:
|
|
9
|
+
// ○ → … → ✓
|
|
10
|
+
// — `opened` and `in_progress` deliberately share the `…` icon so the
|
|
11
|
+
// learner doesn't get confronted with a "you opened it but didn't engage"
|
|
12
|
+
// distinct symbol; the data distinction exists for analytics hooks only.
|
|
13
|
+
import { expect, test } from '@playwright/test';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { dirname, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const FIXTURE_BODY = readFileSync(resolve(FIXTURE_DIR, 'fixtures/curriculum.json'), 'utf-8');
|
|
20
|
+
|
|
21
|
+
test.beforeEach(async ({ page }) => {
|
|
22
|
+
await page.route('**/curriculum.json', (route) =>
|
|
23
|
+
route.fulfill({ contentType: 'application/json', body: FIXTURE_BODY })
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test.describe('lesson lifecycle visual sequence', () => {
|
|
28
|
+
test('○ → … → ✓ across mount + completion', async ({ page }) => {
|
|
29
|
+
await page.goto('/');
|
|
30
|
+
|
|
31
|
+
// Pre-navigation: lesson 1 status icon is ○ (not_started).
|
|
32
|
+
await page.locator('aside nav button').first().click();
|
|
33
|
+
const firstLessonIcon = page.locator('aside nav ul ul button span').first();
|
|
34
|
+
await expect(firstLessonIcon).toHaveText('○');
|
|
35
|
+
|
|
36
|
+
// Navigate into the lesson — `opened` writes immediately and the
|
|
37
|
+
// progress store invalidates; the active row's icon switches to …
|
|
38
|
+
// (visually merged with in_progress per FR-P15).
|
|
39
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
40
|
+
await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
|
|
41
|
+
|
|
42
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
43
|
+
// Allow up to 1 s for the markLessonOpened + invalidateProgress
|
|
44
|
+
// round-trip; visually merged with in_progress.
|
|
45
|
+
await expect(activeIcon).toHaveText('…', { timeout: 1500 });
|
|
46
|
+
|
|
47
|
+
// Wait for the short-text-block sentinel to fire (1 s debounce
|
|
48
|
+
// + observer flush) → completion → ✓.
|
|
49
|
+
await expect(activeIcon).toHaveText('✓', { timeout: 5000 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P9 + FR-P11 regression coverage. Real lesson completion must:
|
|
5
|
+
// 1. Mark the lesson `in_progress` on first navigation.
|
|
6
|
+
// 2. Mark the lesson `complete` once every block fires its
|
|
7
|
+
// completion event — and reflect that with `✓` in the sidebar
|
|
8
|
+
// WITHOUT a page reload.
|
|
9
|
+
// 3. Increment the dashboard's "X of N completed" count.
|
|
10
|
+
// 4. Pre-fill the Next/Finish enabled state on revisit.
|
|
11
|
+
//
|
|
12
|
+
// We use the dedicated `e2e/fixtures/curriculum.json` (see e2e/README.md)
|
|
13
|
+
// so completion behavior is decoupled from whatever curriculum the
|
|
14
|
+
// smoke pipeline happens to build.
|
|
15
|
+
import { expect, test } from '@playwright/test';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const FIXTURE_BODY = readFileSync(resolve(FIXTURE_DIR, 'fixtures/curriculum.json'), 'utf-8');
|
|
22
|
+
|
|
23
|
+
test.beforeEach(async ({ page }) => {
|
|
24
|
+
await page.route('**/curriculum.json', (route) =>
|
|
25
|
+
route.fulfill({ contentType: 'application/json', body: FIXTURE_BODY })
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test.describe('progress reactivity', () => {
|
|
30
|
+
test('navigating into a lesson marks it in_progress in the sidebar', async ({ page }) => {
|
|
31
|
+
await page.goto('/');
|
|
32
|
+
|
|
33
|
+
await page.locator('aside nav button').first().click();
|
|
34
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
35
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
36
|
+
|
|
37
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
38
|
+
await expect(activeIcon).not.toHaveText('○');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('short text-block lesson completes (sidebar ✓ without reload)', async ({ page }) => {
|
|
42
|
+
await page.goto('/');
|
|
43
|
+
await page.locator('aside nav button').first().click();
|
|
44
|
+
// Click the *first* lesson in the first module — `mod-01/lesson-01`
|
|
45
|
+
// of the fixture is a short text block whose sentinel is in view.
|
|
46
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
47
|
+
await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
|
|
48
|
+
|
|
49
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
50
|
+
// Allow up to 5 s for the 1 s sentinel debounce + a generous test
|
|
51
|
+
// budget (CI scheduling jitter can stretch IntersectionObserver).
|
|
52
|
+
await expect(activeIcon).toHaveText('✓', { timeout: 5000 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('completing a lesson increments the dashboard total', async ({ page }) => {
|
|
56
|
+
await page.goto('/');
|
|
57
|
+
const dashLabel = page.getByText(/of \d+ lessons completed/);
|
|
58
|
+
await expect(dashLabel).toContainText(/^0 of \d+/);
|
|
59
|
+
|
|
60
|
+
await page.locator('aside nav button').first().click();
|
|
61
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
62
|
+
await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
|
|
63
|
+
await page
|
|
64
|
+
.locator('aside nav ul ul button.bg-blue-100 span')
|
|
65
|
+
.first()
|
|
66
|
+
.waitFor({ state: 'visible' });
|
|
67
|
+
|
|
68
|
+
// Wait for completion, then go home to read the dashboard.
|
|
69
|
+
await expect(
|
|
70
|
+
page.locator('aside nav ul ul button.bg-blue-100 span').first()
|
|
71
|
+
).toHaveText('✓', { timeout: 5000 });
|
|
72
|
+
|
|
73
|
+
await page.goto('/');
|
|
74
|
+
await expect(dashLabel).toContainText(/^1 of \d+/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('revisiting a complete lesson pre-fills Next as enabled', async ({ page }) => {
|
|
78
|
+
await page.goto('/');
|
|
79
|
+
await page.locator('aside nav button').first().click();
|
|
80
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
81
|
+
await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
|
|
82
|
+
|
|
83
|
+
// Wait for first-time completion.
|
|
84
|
+
await expect(
|
|
85
|
+
page.locator('aside nav ul ul button.bg-blue-100 span').first()
|
|
86
|
+
).toHaveText('✓', { timeout: 5000 });
|
|
87
|
+
|
|
88
|
+
// Navigate away then back.
|
|
89
|
+
await page.locator('aside nav ul ul button').nth(1).click();
|
|
90
|
+
await expect(page).toHaveURL(/\/mod-01\/lesson-02$/);
|
|
91
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
92
|
+
await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
|
|
93
|
+
|
|
94
|
+
// Next/Finish is enabled immediately on revisit (FR-P2 / I.g revisit).
|
|
95
|
+
const nextBtn = page.getByRole('button', { name: /Next|Finish/ });
|
|
96
|
+
await expect(nextBtn).toBeEnabled({ timeout: 1000 });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P13 regression coverage: a tall text block must not mark its
|
|
5
|
+
// lesson complete until the learner scrolls to the end-of-block
|
|
6
|
+
// sentinel. We exercise this directly:
|
|
7
|
+
//
|
|
8
|
+
// 1. Load the lesson; without scrolling, wait long enough that the
|
|
9
|
+
// 1 s debounce would have fired several times — assert no `✓`.
|
|
10
|
+
// 2. Scroll `<main>` to the bottom; assert `✓` appears within 2 s.
|
|
11
|
+
//
|
|
12
|
+
// Uses `e2e/fixtures/curriculum.json`'s `mod-01/lesson-02`, which has
|
|
13
|
+
// a 200vh spacer so the sentinel is below the fold on initial render.
|
|
14
|
+
import { expect, test } from '@playwright/test';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { dirname, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const FIXTURE_BODY = readFileSync(resolve(FIXTURE_DIR, 'fixtures/curriculum.json'), 'utf-8');
|
|
21
|
+
|
|
22
|
+
test.beforeEach(async ({ page }) => {
|
|
23
|
+
await page.route('**/curriculum.json', (route) =>
|
|
24
|
+
route.fulfill({ contentType: 'application/json', body: FIXTURE_BODY })
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test.describe('TextBlock end-of-block sentinel (tall block)', () => {
|
|
29
|
+
test('tall lesson does not complete without scroll', async ({ page }) => {
|
|
30
|
+
await page.goto('/mod-01/lesson-02');
|
|
31
|
+
// Sidebar must auto-expand mod-01.
|
|
32
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
33
|
+
await activeIcon.waitFor({ state: 'visible' });
|
|
34
|
+
|
|
35
|
+
// Wait well past the 1 s debounce; without scroll the sentinel
|
|
36
|
+
// stays below the fold and `textcomplete` must not fire.
|
|
37
|
+
await page.waitForTimeout(3000);
|
|
38
|
+
await expect(activeIcon).not.toHaveText('✓');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('scrolling <main> to the bottom triggers completion', async ({ page }) => {
|
|
42
|
+
await page.goto('/mod-01/lesson-02');
|
|
43
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
44
|
+
await activeIcon.waitFor({ state: 'visible' });
|
|
45
|
+
|
|
46
|
+
// Sanity: not yet complete.
|
|
47
|
+
await page.waitForTimeout(500);
|
|
48
|
+
await expect(activeIcon).not.toHaveText('✓');
|
|
49
|
+
|
|
50
|
+
// Scroll the inner `<main>` (the lesson content scroll container)
|
|
51
|
+
// to the bottom so the sentinel enters the viewport.
|
|
52
|
+
await page.locator('main').evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
|
53
|
+
|
|
54
|
+
// 1 s debounce + observer flush; allow up to 3 s.
|
|
55
|
+
await expect(activeIcon).toHaveText('✓', { timeout: 3000 });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
28
28
|
"@tailwindcss/typography": "^0.5.16",
|
|
29
29
|
"@tailwindcss/vite": "^4.0.0",
|
|
30
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
31
|
+
"@testing-library/svelte": "^5.2.0",
|
|
30
32
|
"@types/sql.js": "^1.4.11",
|
|
31
33
|
"jsdom": "^25.0.0",
|
|
32
34
|
"prettier": "^3.0.0",
|
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
function statusClass(lessonId: string): string {
|
|
31
31
|
const s = progress[lessonId]?.status;
|
|
32
32
|
if (s === 'complete') return 'text-green-600';
|
|
33
|
-
|
|
33
|
+
// `opened` (Story I.p) shares the in_progress visual on purpose —
|
|
34
|
+
// learners shouldn't see "I opened it but didn't engage" as a
|
|
35
|
+
// distinct sidebar symbol; the distinction is data-only.
|
|
36
|
+
if (s === 'in_progress' || s === 'opened') return 'text-blue-500';
|
|
34
37
|
return 'text-gray-400';
|
|
35
38
|
}
|
|
36
39
|
|
learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import {
|
|
4
|
+
getLessonProgress,
|
|
5
|
+
markLessonComplete,
|
|
6
|
+
markLessonInProgress,
|
|
7
|
+
markLessonOpened
|
|
8
|
+
} from '$lib/db/index.js';
|
|
9
|
+
import { curriculum } from '$lib/stores/curriculum.js';
|
|
10
|
+
import { invalidateProgress } from '$lib/stores/progress.js';
|
|
11
|
+
import type { Lesson, QuizScore } from '$lib/types/index.js';
|
|
12
|
+
import ContentBlock from './ContentBlock.svelte';
|
|
13
|
+
import Navigation from './Navigation.svelte';
|
|
14
|
+
import { contentBlockKey } from './lesson-view.helpers.js';
|
|
15
|
+
import { onMount } from 'svelte';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lifecycle event payload (Story I.p / FR-P15). Each callback fires
|
|
19
|
+
* at most once per mount session and only when a meaningful state
|
|
20
|
+
* transition occurred:
|
|
21
|
+
* - `onlessonopen` — every mount.
|
|
22
|
+
* - `onlessonengage` — first block-completion event of the mount
|
|
23
|
+
* (suppressed on revisits to a `complete` lesson).
|
|
24
|
+
* - `onlessoncomplete` — when every content block has fired
|
|
25
|
+
* completion (suppressed on revisits to a `complete` lesson; for
|
|
26
|
+
* zero-block lessons fires immediately after `onlessonopen`).
|
|
27
|
+
* No internal subscribers exist today — these are forward-compatible
|
|
28
|
+
* hooks for future analytics / telemetry adapters.
|
|
29
|
+
*/
|
|
30
|
+
export interface LessonLifecycleDetail {
|
|
31
|
+
moduleId: string;
|
|
32
|
+
lessonId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
lesson: Lesson;
|
|
37
|
+
moduleId: string;
|
|
38
|
+
onlessonopen?: (detail: LessonLifecycleDetail) => void;
|
|
39
|
+
onlessonengage?: (detail: LessonLifecycleDetail) => void;
|
|
40
|
+
onlessoncomplete?: (detail: LessonLifecycleDetail) => void;
|
|
41
|
+
}
|
|
42
|
+
let {
|
|
43
|
+
lesson,
|
|
44
|
+
moduleId,
|
|
45
|
+
onlessonopen,
|
|
46
|
+
onlessonengage,
|
|
47
|
+
onlessoncomplete
|
|
48
|
+
}: Props = $props();
|
|
49
|
+
|
|
50
|
+
let allBlocksComplete = $state(false);
|
|
51
|
+
let completedBlocks = $state(new Set<number>());
|
|
52
|
+
// Tracks whether we have already promoted this mount session to
|
|
53
|
+
// `in_progress` — first block-completion flips it; subsequent block
|
|
54
|
+
// completions re-use the existing row without reissuing the SQL.
|
|
55
|
+
let engaged = $state(false);
|
|
56
|
+
|
|
57
|
+
const lessonComplete = $derived(
|
|
58
|
+
allBlocksComplete || completedBlocks.size === lesson.content_blocks.length
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
function fireOpen() {
|
|
62
|
+
onlessonopen?.({ moduleId, lessonId: lesson.id });
|
|
63
|
+
}
|
|
64
|
+
function fireComplete() {
|
|
65
|
+
onlessoncomplete?.({ moduleId, lessonId: lesson.id });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onMount(async () => {
|
|
69
|
+
// Every mount records an open before any other state transition.
|
|
70
|
+
await markLessonOpened(moduleId, lesson.id);
|
|
71
|
+
fireOpen();
|
|
72
|
+
|
|
73
|
+
// Zero-block edge case — instant complete after open.
|
|
74
|
+
if (lesson.content_blocks.length === 0) {
|
|
75
|
+
allBlocksComplete = true;
|
|
76
|
+
await markLessonComplete(moduleId, lesson.id);
|
|
77
|
+
await invalidateProgress($curriculum);
|
|
78
|
+
fireComplete();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Revisit: pre-fill nav so Next/Finish is enabled immediately. No
|
|
83
|
+
// engage / complete events fire on revisit — no transition occurs.
|
|
84
|
+
const existing = await getLessonProgress(moduleId, lesson.id);
|
|
85
|
+
if (existing?.status === 'complete') {
|
|
86
|
+
allBlocksComplete = true;
|
|
87
|
+
engaged = true; // suppress redundant `markLessonInProgress` if a block fires
|
|
88
|
+
}
|
|
89
|
+
// First-engage promotion is now deferred to `handleBlockComplete`
|
|
90
|
+
// so a learner who opens a lesson but engages with no content is
|
|
91
|
+
// distinguishable from one genuinely partway through.
|
|
92
|
+
await invalidateProgress($curriculum);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function handleBlockComplete(blockIndex: number) {
|
|
96
|
+
if (allBlocksComplete) return;
|
|
97
|
+
completedBlocks.add(blockIndex);
|
|
98
|
+
completedBlocks = new Set(completedBlocks);
|
|
99
|
+
|
|
100
|
+
// First engagement of the mount session: promote to in_progress
|
|
101
|
+
// and emit `lessonengage`.
|
|
102
|
+
if (!engaged) {
|
|
103
|
+
engaged = true;
|
|
104
|
+
await markLessonInProgress(moduleId, lesson.id);
|
|
105
|
+
onlessonengage?.({ moduleId, lessonId: lesson.id });
|
|
106
|
+
await invalidateProgress($curriculum);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (completedBlocks.size === lesson.content_blocks.length) {
|
|
110
|
+
await markLessonComplete(moduleId, lesson.id);
|
|
111
|
+
// Refreshing the progress store is enough to drive the
|
|
112
|
+
// `unlock_module_on_complete` cascade: locking utilities in
|
|
113
|
+
// `$lib/utils/locking.ts` re-derive sibling-optional and
|
|
114
|
+
// next-module-unlocked state from the new `complete` status —
|
|
115
|
+
// no additional DB write or extra invalidation is required.
|
|
116
|
+
await invalidateProgress($curriculum);
|
|
117
|
+
fireComplete();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function handleQuizComplete(_score: QuizScore) {
|
|
122
|
+
// Score already persisted by QuizBlock; could trigger further logic here
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<article class="mx-auto max-w-3xl space-y-8 py-6">
|
|
127
|
+
<header>
|
|
128
|
+
<h1 class="text-2xl font-bold text-gray-900">{lesson.title}</h1>
|
|
129
|
+
</header>
|
|
130
|
+
|
|
131
|
+
<div class="space-y-8">
|
|
132
|
+
{#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
|
|
133
|
+
<section>
|
|
134
|
+
<ContentBlock
|
|
135
|
+
{block}
|
|
136
|
+
blockIndex={i}
|
|
137
|
+
onblockcomplete={handleBlockComplete}
|
|
138
|
+
onquizcomplete={handleQuizComplete}
|
|
139
|
+
/>
|
|
140
|
+
</section>
|
|
141
|
+
{/each}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<Navigation disabled={!lessonComplete} />
|
|
145
|
+
</article>
|