learningfoundry 0.40.0__tar.gz → 0.45.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.40.0 → learningfoundry-0.45.0}/CHANGELOG.md +54 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/PKG-INFO +38 -2
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/README.md +37 -1
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/pyproject.toml +1 -1
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/__init__.py +1 -1
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/config.py +15 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/resolver.py +13 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/schema_v1.py +11 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +12 -3
- {learningfoundry-0.40.0 → learningfoundry-0.45.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonList.svelte +28 -9
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +81 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +68 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +103 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +10 -5
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +45 -20
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +118 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0/src/learningfoundry}/sveltekit_template/src/lib/components/QuizBlock.svelte +8 -1
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +51 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +78 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +130 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +96 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +48 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +105 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +65 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +39 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +98 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +28 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +12 -1
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +238 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +147 -0
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +64 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +12 -18
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +2 -22
- {learningfoundry-0.40.0 → learningfoundry-0.45.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -3
- learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +93 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +12 -3
- {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/LessonList.svelte +28 -9
- learningfoundry-0.45.0/sveltekit_template/src/lib/components/LessonView.svelte +81 -0
- learningfoundry-0.45.0/sveltekit_template/src/lib/components/ModuleList.svelte +103 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/Navigation.svelte +10 -5
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +45 -20
- {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +8 -1
- learningfoundry-0.45.0/sveltekit_template/src/lib/components/TextBlock.svelte +51 -0
- learningfoundry-0.45.0/sveltekit_template/src/lib/components/VideoBlock.svelte +130 -0
- learningfoundry-0.45.0/sveltekit_template/src/lib/components/module-list.helpers.ts +105 -0
- learningfoundry-0.45.0/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +39 -0
- learningfoundry-0.45.0/sveltekit_template/src/lib/stores/progress.ts +28 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/types/index.ts +12 -1
- learningfoundry-0.45.0/sveltekit_template/src/lib/utils/locking.ts +147 -0
- learningfoundry-0.45.0/sveltekit_template/src/lib/utils/viewport-completion.ts +64 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/routes/+layout.svelte +12 -19
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/routes/+page.svelte +2 -22
- {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.45.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +4 -18
- learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -44
- learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -67
- learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -16
- learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -45
- learningfoundry-0.40.0/sveltekit_template/src/lib/components/LessonView.svelte +0 -44
- learningfoundry-0.40.0/sveltekit_template/src/lib/components/ModuleList.svelte +0 -67
- learningfoundry-0.40.0/sveltekit_template/src/lib/components/TextBlock.svelte +0 -16
- learningfoundry-0.40.0/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -45
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/.gitignore +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/LICENSE +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/asset_resolver.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/cli.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/generator.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/pipeline.py +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
|
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.45.0] - 2026-04-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Locking and unlocking UI.** Frontend implementation of the locking model introduced in v0.44.0:
|
|
15
|
+
- New `$lib/utils/locking.ts` with pure functions `isModuleLocked`, `isLessonLocked`, `getOptionalLessons`, `isModuleComplete`, plus convenience set helpers `lockedModuleIds` / `lockedLessonIds`.
|
|
16
|
+
- `ModuleList.svelte` renders a Lucide `Lock` icon, suppresses expansion, and skips the active-module highlight for locked modules.
|
|
17
|
+
- `LessonList.svelte` shows a `◇` indicator for optional lessons (sibling lessons within a module whose `unlock_module_on_complete` lesson has been completed) and renders locked lessons as muted, non-clickable rows.
|
|
18
|
+
- `ProgressDashboard.svelte` uses `isModuleComplete` for per-module status (treats optional lessons as not blocking module completion) while still counting all completed lessons toward the curriculum-level progress bar.
|
|
19
|
+
- Type extensions: `LessonStatus` gains `'optional'`; `Lesson.unlock_module_on_complete?`, `Module.locked?`, `Curriculum.locking?`, and a new `LockingConfig` interface.
|
|
20
|
+
- The unlock cascade is fully reactive: completing an `unlock_module_on_complete` lesson refreshes `progressStore` once via `invalidateProgress`, and all locked/optional state re-derives automatically — no extra DB writes or store updates required.
|
|
21
|
+
|
|
22
|
+
## [0.44.0] - 2026-04-30
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Locking configuration schema.** Python-side schema, resolver, and config support for sequential content access control:
|
|
27
|
+
- `LockingConfig` Pydantic model (`sequential`, `lesson_sequential`) on `CurriculumDef`.
|
|
28
|
+
- `Module.locked: bool | None` per-module override.
|
|
29
|
+
- `Lesson.unlock_module_on_complete: bool` gateway-lesson flag.
|
|
30
|
+
- `QuizBlock.pass_threshold: float` (0.0–1.0) for quiz completion scoring.
|
|
31
|
+
- Global config (`~/.config/learningfoundry/config.yml`) gains `locking` block with the same fields.
|
|
32
|
+
- Config hierarchy: global defaults → curriculum YAML `locking` → per-module `locked` override.
|
|
33
|
+
- All fields propagate through the resolver into `curriculum.json` for frontend consumption (Story I.j).
|
|
34
|
+
|
|
35
|
+
## [0.43.0] - 2026-04-30
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **Curriculum-level progress bar on the dashboard.** `ProgressDashboard.svelte` now renders a summary bar above the module cards showing `"{totalComplete} of {totalLessons} lessons completed"`. Computed reactively from `progressStore` — updates live when lessons complete during the session. Hidden when the curriculum has zero lessons. The dashboard `+page.svelte` no longer does its own one-shot progress fetch; it reads from the shared `progressStore` populated by the layout.
|
|
40
|
+
|
|
41
|
+
## [0.42.0] - 2026-04-30
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **Block completion events drive lesson auto-complete.** Each content block fires an independent completion event when sufficiently engaged with: `TextBlock` after 1 s in the viewport (IntersectionObserver + debounce timer), `VideoBlock` on YouTube IFrame Player API `ENDED` state (falls back to 3 s viewport if API fails to load within 5 s), `QuizBlock` when score ≥ `passThreshold` (default 0.0). `LessonView` tracks which blocks have completed; when all have fired, the lesson is marked complete in SQLite and the sidebar updates immediately.
|
|
46
|
+
- **Reactive progress store** (`$lib/stores/progress.ts`). `progressStore` is a writable Svelte store holding `Record<string, ModuleProgress>`. `invalidateProgress(curriculum)` re-fetches all module progress from SQLite and writes to the store. `+layout.svelte` subscribes to this store instead of a one-shot `$effect` fetch; lesson completions call `invalidateProgress` so the sidebar reflects changes without a page reload.
|
|
47
|
+
- **Revisit behaviour.** On mount, `LessonView` reads the lesson's current DB status; if already `complete`, all blocks are pre-filled as done so the Next/Finish button is immediately active.
|
|
48
|
+
- **Zero-block edge case.** A lesson with no content blocks is treated as immediately complete on mount.
|
|
49
|
+
- **`QuizManifest.passThreshold`** added to TypeScript types for future quiz scoring threshold support.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **Next/Finish no longer trigger completion marking.** Navigation is decoupled from completion: `Navigation.svelte` handles its own routing (Next → `navigateTo`, Finish → `goto('/')`) and accepts a `disabled` prop. The `onComplete` callback prop has been removed. `LessonView` no longer has `handleNavComplete` or `oncomplete`.
|
|
54
|
+
- **`+page.svelte`** migrated from deprecated `$app/stores` to `$app/state` for route params.
|
|
55
|
+
|
|
56
|
+
## [0.41.0] - 2026-04-30
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **Finish button on last lesson now navigates to the dashboard.** `+page.svelte` was not passing an `oncomplete` handler to `<LessonView>`; clicking Finish was a no-op. It now calls `goto('/')` to redirect to the progress dashboard.
|
|
61
|
+
- **Sidebar module expand/contract no longer reverts immediately.** The `$effect` in `ModuleList.svelte` read `expandedModuleId` (the value it writes), creating a self-dependency that overwrote manual toggles on every re-run. A separate `lastAutoExpandedModuleId` state variable breaks the cycle — the effect only fires when `currentPosition.moduleId` changes to a genuinely new value.
|
|
62
|
+
- **Active module in sidebar is now visually highlighted.** The module card containing the current lesson receives a left-border accent (`border-l-2 border-l-blue-500`) and a light background tint (`bg-blue-50`).
|
|
63
|
+
|
|
10
64
|
## [0.40.0] - 2026-04-29
|
|
11
65
|
|
|
12
66
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learningfoundry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.45.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
|
|
@@ -48,6 +48,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
|
|
|
48
48
|
- [Video blocks](#video-blocks)
|
|
49
49
|
- [Lesson titles and markdown headings](#lesson-titles-and-markdown-headings)
|
|
50
50
|
- [Images and assets](#images-and-assets)
|
|
51
|
+
- [Content locking](#content-locking)
|
|
51
52
|
- [Configuration File](#configuration-file)
|
|
52
53
|
- [Development Setup](#development-setup)
|
|
53
54
|
|
|
@@ -389,9 +390,40 @@ For production deployment to a CDN, just run `cd dist && pnpm build` — the `st
|
|
|
389
390
|
|
|
390
391
|
---
|
|
391
392
|
|
|
393
|
+
## Content locking
|
|
394
|
+
|
|
395
|
+
Control access to modules and lessons with a three-level configuration hierarchy (most local wins):
|
|
396
|
+
|
|
397
|
+
1. **Per-module `locked`** — explicit `true`/`false` override; trumps everything.
|
|
398
|
+
2. **Curriculum `locking.sequential`** — when true, module N+1 requires module N complete.
|
|
399
|
+
3. **Global config `locking.sequential`** — project-wide default (see Configuration File below).
|
|
400
|
+
|
|
401
|
+
```yaml
|
|
402
|
+
curriculum:
|
|
403
|
+
locking:
|
|
404
|
+
sequential: true # modules must be completed in order
|
|
405
|
+
lesson_sequential: false # lessons within a module are free-order
|
|
406
|
+
|
|
407
|
+
modules:
|
|
408
|
+
- id: mod-01
|
|
409
|
+
locked: false # always accessible regardless of sequential
|
|
410
|
+
lessons:
|
|
411
|
+
- id: lesson-01
|
|
412
|
+
unlock_module_on_complete: true # completing this unlocks siblings + next module
|
|
413
|
+
content_blocks:
|
|
414
|
+
- type: quiz
|
|
415
|
+
source: quizazz
|
|
416
|
+
ref: assessments/quiz.yml
|
|
417
|
+
pass_threshold: 0.7 # 70% required to count as passed
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
`unlock_module_on_complete` is useful for "gateway" lessons — a single assessment that, once passed, opens the rest of the module and the next one.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
392
424
|
## Configuration File
|
|
393
425
|
|
|
394
|
-
An optional config file can set defaults for logging. The CLI always takes precedence.
|
|
426
|
+
An optional config file can set defaults for logging and locking. The CLI always takes precedence.
|
|
395
427
|
|
|
396
428
|
**Default location:** `~/.config/learningfoundry/config.yml`
|
|
397
429
|
|
|
@@ -399,6 +431,10 @@ An optional config file can set defaults for logging. The CLI always takes prece
|
|
|
399
431
|
logging:
|
|
400
432
|
level: INFO # DEBUG | INFO | WARNING | ERROR
|
|
401
433
|
output: stdout # stdout | stderr
|
|
434
|
+
|
|
435
|
+
locking:
|
|
436
|
+
sequential: false # default for all curricula on this machine
|
|
437
|
+
lesson_sequential: false
|
|
402
438
|
```
|
|
403
439
|
|
|
404
440
|
Pass a custom config location with `-c / --config`.
|
|
@@ -19,6 +19,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
|
|
|
19
19
|
- [Video blocks](#video-blocks)
|
|
20
20
|
- [Lesson titles and markdown headings](#lesson-titles-and-markdown-headings)
|
|
21
21
|
- [Images and assets](#images-and-assets)
|
|
22
|
+
- [Content locking](#content-locking)
|
|
22
23
|
- [Configuration File](#configuration-file)
|
|
23
24
|
- [Development Setup](#development-setup)
|
|
24
25
|
|
|
@@ -360,9 +361,40 @@ For production deployment to a CDN, just run `cd dist && pnpm build` — the `st
|
|
|
360
361
|
|
|
361
362
|
---
|
|
362
363
|
|
|
364
|
+
## Content locking
|
|
365
|
+
|
|
366
|
+
Control access to modules and lessons with a three-level configuration hierarchy (most local wins):
|
|
367
|
+
|
|
368
|
+
1. **Per-module `locked`** — explicit `true`/`false` override; trumps everything.
|
|
369
|
+
2. **Curriculum `locking.sequential`** — when true, module N+1 requires module N complete.
|
|
370
|
+
3. **Global config `locking.sequential`** — project-wide default (see Configuration File below).
|
|
371
|
+
|
|
372
|
+
```yaml
|
|
373
|
+
curriculum:
|
|
374
|
+
locking:
|
|
375
|
+
sequential: true # modules must be completed in order
|
|
376
|
+
lesson_sequential: false # lessons within a module are free-order
|
|
377
|
+
|
|
378
|
+
modules:
|
|
379
|
+
- id: mod-01
|
|
380
|
+
locked: false # always accessible regardless of sequential
|
|
381
|
+
lessons:
|
|
382
|
+
- id: lesson-01
|
|
383
|
+
unlock_module_on_complete: true # completing this unlocks siblings + next module
|
|
384
|
+
content_blocks:
|
|
385
|
+
- type: quiz
|
|
386
|
+
source: quizazz
|
|
387
|
+
ref: assessments/quiz.yml
|
|
388
|
+
pass_threshold: 0.7 # 70% required to count as passed
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`unlock_module_on_complete` is useful for "gateway" lessons — a single assessment that, once passed, opens the rest of the module and the next one.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
363
395
|
## Configuration File
|
|
364
396
|
|
|
365
|
-
An optional config file can set defaults for logging. The CLI always takes precedence.
|
|
397
|
+
An optional config file can set defaults for logging and locking. The CLI always takes precedence.
|
|
366
398
|
|
|
367
399
|
**Default location:** `~/.config/learningfoundry/config.yml`
|
|
368
400
|
|
|
@@ -370,6 +402,10 @@ An optional config file can set defaults for logging. The CLI always takes prece
|
|
|
370
402
|
logging:
|
|
371
403
|
level: INFO # DEBUG | INFO | WARNING | ERROR
|
|
372
404
|
output: stdout # stdout | stderr
|
|
405
|
+
|
|
406
|
+
locking:
|
|
407
|
+
sequential: false # default for all curricula on this machine
|
|
408
|
+
lesson_sequential: false
|
|
373
409
|
```
|
|
374
410
|
|
|
375
411
|
Pass a custom config location with `-c / --config`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "learningfoundry"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.45.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"
|
|
@@ -16,6 +16,7 @@ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "learningfoundry" / "config.yml"
|
|
|
16
16
|
|
|
17
17
|
_KNOWN_KEYS: dict[str, set[str]] = {
|
|
18
18
|
"logging": {"level", "output"},
|
|
19
|
+
"locking": {"sequential", "lesson_sequential"},
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
|
|
@@ -25,9 +26,16 @@ class LoggingConfig:
|
|
|
25
26
|
output: str = "stdout"
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
@dataclass
|
|
30
|
+
class LockingConfig:
|
|
31
|
+
sequential: bool = False
|
|
32
|
+
lesson_sequential: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
28
35
|
@dataclass
|
|
29
36
|
class AppConfig:
|
|
30
37
|
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
38
|
+
locking: LockingConfig = field(default_factory=LockingConfig)
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
def load_config(
|
|
@@ -66,6 +74,13 @@ def load_config(
|
|
|
66
74
|
config.logging.level = logging_raw["level"]
|
|
67
75
|
if "output" in logging_raw:
|
|
68
76
|
config.logging.output = logging_raw["output"]
|
|
77
|
+
locking_raw = raw.get("locking", {})
|
|
78
|
+
if "sequential" in locking_raw:
|
|
79
|
+
config.locking.sequential = bool(locking_raw["sequential"])
|
|
80
|
+
if "lesson_sequential" in locking_raw:
|
|
81
|
+
config.locking.lesson_sequential = bool(
|
|
82
|
+
locking_raw["lesson_sequential"]
|
|
83
|
+
)
|
|
69
84
|
|
|
70
85
|
if cli_overrides:
|
|
71
86
|
if "log_level" in cli_overrides and cli_overrides["log_level"] is not None:
|
|
@@ -40,6 +40,7 @@ class ResolvedContentBlock:
|
|
|
40
40
|
class ResolvedLesson:
|
|
41
41
|
id: str
|
|
42
42
|
title: str
|
|
43
|
+
unlock_module_on_complete: bool = False
|
|
43
44
|
content_blocks: list[ResolvedContentBlock] = field(default_factory=list)
|
|
44
45
|
|
|
45
46
|
|
|
@@ -48,6 +49,7 @@ class ResolvedModule:
|
|
|
48
49
|
id: str
|
|
49
50
|
title: str
|
|
50
51
|
description: str
|
|
52
|
+
locked: bool | None
|
|
51
53
|
pre_assessment: dict[str, Any] | None
|
|
52
54
|
post_assessment: dict[str, Any] | None
|
|
53
55
|
lessons: list[ResolvedLesson] = field(default_factory=list)
|
|
@@ -58,6 +60,7 @@ class ResolvedCurriculum:
|
|
|
58
60
|
version: str
|
|
59
61
|
title: str
|
|
60
62
|
description: str
|
|
63
|
+
locking: dict[str, Any] = field(default_factory=dict)
|
|
61
64
|
modules: list[ResolvedModule] = field(default_factory=list)
|
|
62
65
|
# Image assets referenced from any text block's markdown, deduped by
|
|
63
66
|
# content hash. Carried out-of-band — the generator copies these into
|
|
@@ -122,10 +125,17 @@ def resolve_curriculum(
|
|
|
122
125
|
)
|
|
123
126
|
)
|
|
124
127
|
|
|
128
|
+
locking = curriculum.curriculum.locking
|
|
129
|
+
locking_dict: dict[str, Any] = {
|
|
130
|
+
"sequential": locking.sequential,
|
|
131
|
+
"lesson_sequential": locking.lesson_sequential,
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
return ResolvedCurriculum(
|
|
126
135
|
version=curriculum.version,
|
|
127
136
|
title=curriculum.curriculum.title,
|
|
128
137
|
description=curriculum.curriculum.description,
|
|
138
|
+
locking=locking_dict,
|
|
129
139
|
modules=resolved_modules,
|
|
130
140
|
assets=list(assets_by_dest.values()),
|
|
131
141
|
)
|
|
@@ -174,6 +184,7 @@ def _resolve_module(
|
|
|
174
184
|
id=module.id,
|
|
175
185
|
title=module.title,
|
|
176
186
|
description=module.description,
|
|
187
|
+
locked=module.locked,
|
|
177
188
|
pre_assessment=pre,
|
|
178
189
|
post_assessment=post,
|
|
179
190
|
lessons=resolved_lessons,
|
|
@@ -206,6 +217,7 @@ def _resolve_lesson(
|
|
|
206
217
|
return ResolvedLesson(
|
|
207
218
|
id=lesson.id,
|
|
208
219
|
title=lesson.title,
|
|
220
|
+
unlock_module_on_complete=lesson.unlock_module_on_complete,
|
|
209
221
|
content_blocks=resolved_blocks,
|
|
210
222
|
)
|
|
211
223
|
|
|
@@ -228,6 +240,7 @@ def _resolve_block(
|
|
|
228
240
|
manifest = quiz_provider.compile_assessment(
|
|
229
241
|
Path(block.ref), base_dir
|
|
230
242
|
)
|
|
243
|
+
manifest["pass_threshold"] = block.pass_threshold
|
|
231
244
|
return ResolvedContentBlock(
|
|
232
245
|
type="quiz", source=block.source, ref=block.ref, content=manifest
|
|
233
246
|
)
|
|
@@ -63,6 +63,7 @@ class QuizBlock(BaseModel):
|
|
|
63
63
|
type: Literal["quiz"]
|
|
64
64
|
source: str
|
|
65
65
|
ref: str
|
|
66
|
+
pass_threshold: float = Field(0.0, ge=0.0, le=1.0)
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
class ExerciseBlock(BaseModel):
|
|
@@ -86,6 +87,7 @@ ContentBlock = Annotated[
|
|
|
86
87
|
class Lesson(BaseModel):
|
|
87
88
|
id: str
|
|
88
89
|
title: str
|
|
90
|
+
unlock_module_on_complete: bool = False
|
|
89
91
|
content_blocks: list[ContentBlock]
|
|
90
92
|
|
|
91
93
|
@field_validator("id")
|
|
@@ -98,6 +100,7 @@ class Module(BaseModel):
|
|
|
98
100
|
id: str
|
|
99
101
|
title: str
|
|
100
102
|
description: str = ""
|
|
103
|
+
locked: bool | None = None
|
|
101
104
|
pre_assessment: AssessmentRef | None = None
|
|
102
105
|
post_assessment: AssessmentRef | None = None
|
|
103
106
|
lessons: list[Lesson]
|
|
@@ -114,9 +117,17 @@ class Module(BaseModel):
|
|
|
114
117
|
return self
|
|
115
118
|
|
|
116
119
|
|
|
120
|
+
class LockingConfig(BaseModel):
|
|
121
|
+
"""Curriculum-level content locking configuration."""
|
|
122
|
+
|
|
123
|
+
sequential: bool = False
|
|
124
|
+
lesson_sequential: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
117
127
|
class CurriculumDef(BaseModel):
|
|
118
128
|
title: str
|
|
119
129
|
description: str = ""
|
|
130
|
+
locking: LockingConfig = Field(default_factory=LockingConfig)
|
|
120
131
|
modules: list[Module]
|
|
121
132
|
|
|
122
133
|
@model_validator(mode="after")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<!--
|
|
3
3
|
Dispatcher component — renders the correct block component based on `block.type`.
|
|
4
|
+
Forwards child completion events upward as `onblockcomplete(blockIndex)`.
|
|
4
5
|
-->
|
|
5
6
|
<script lang="ts">
|
|
6
7
|
import type { ContentBlock, QuizScore } from '$lib/types/index.js';
|
|
@@ -20,20 +21,28 @@
|
|
|
20
21
|
|
|
21
22
|
interface Props {
|
|
22
23
|
block: ContentBlock;
|
|
24
|
+
blockIndex: number;
|
|
25
|
+
onblockcomplete?: (blockIndex: number) => void;
|
|
23
26
|
onquizcomplete?: (score: QuizScore) => void;
|
|
24
27
|
}
|
|
25
|
-
let { block, onquizcomplete }: Props = $props();
|
|
28
|
+
let { block, blockIndex, onblockcomplete, onquizcomplete }: Props = $props();
|
|
29
|
+
|
|
30
|
+
function handleBlockComplete() {
|
|
31
|
+
onblockcomplete?.(blockIndex);
|
|
32
|
+
}
|
|
26
33
|
</script>
|
|
27
34
|
|
|
28
35
|
{#if block.type === 'text'}
|
|
29
|
-
<TextBlock content={block.content as TextContent} />
|
|
36
|
+
<TextBlock content={block.content as TextContent} ontextcomplete={handleBlockComplete} />
|
|
30
37
|
{:else if block.type === 'video'}
|
|
31
|
-
<VideoBlock content={block.content as VideoContent} />
|
|
38
|
+
<VideoBlock content={block.content as VideoContent} onvideocomplete={handleBlockComplete} />
|
|
32
39
|
{:else if block.type === 'quiz'}
|
|
33
40
|
<QuizBlock
|
|
34
41
|
manifest={block.content as QuizManifest}
|
|
35
42
|
quizRef={block.ref ?? ''}
|
|
43
|
+
passThreshold={(block.content as QuizManifest).passThreshold ?? 0.0}
|
|
36
44
|
oncomplete={onquizcomplete}
|
|
45
|
+
onquizcomplete={handleBlockComplete}
|
|
37
46
|
/>
|
|
38
47
|
{:else if block.type === 'exercise'}
|
|
39
48
|
<ExerciseBlock content={block.content as ExerciseContent} />
|
|
@@ -2,19 +2,27 @@
|
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import { currentPosition, navigateTo } from '$lib/stores/curriculum.js';
|
|
4
4
|
import type { Lesson, LessonProgress } from '$lib/types/index.js';
|
|
5
|
+
import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
moduleId: string;
|
|
8
9
|
lessons: Lesson[];
|
|
9
10
|
progress?: Record<string, LessonProgress>;
|
|
11
|
+
optionalLessons?: Set<string>;
|
|
12
|
+
lockedLessons?: Set<string>;
|
|
10
13
|
}
|
|
11
|
-
let {
|
|
14
|
+
let {
|
|
15
|
+
moduleId,
|
|
16
|
+
lessons,
|
|
17
|
+
progress = {},
|
|
18
|
+
optionalLessons = new Set(),
|
|
19
|
+
lockedLessons = new Set()
|
|
20
|
+
}: Props = $props();
|
|
12
21
|
|
|
13
22
|
function statusIcon(lessonId: string): string {
|
|
14
23
|
const s = progress[lessonId]?.status;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return '○';
|
|
24
|
+
const concrete = s === 'optional' ? undefined : s;
|
|
25
|
+
return lessonStatusIcon(lessonId, concrete, optionalLessons);
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
function statusClass(lessonId: string): string {
|
|
@@ -23,21 +31,32 @@
|
|
|
23
31
|
if (s === 'in_progress') return 'text-blue-500';
|
|
24
32
|
return 'text-gray-400';
|
|
25
33
|
}
|
|
34
|
+
|
|
35
|
+
function handleClick(lessonId: string) {
|
|
36
|
+
if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
|
|
37
|
+
navigateTo(moduleId, lessonId);
|
|
38
|
+
}
|
|
26
39
|
</script>
|
|
27
40
|
|
|
28
41
|
<ul class="space-y-1">
|
|
29
42
|
{#each lessons as lesson (lesson.id)}
|
|
30
43
|
{@const isActive =
|
|
31
44
|
$currentPosition?.moduleId === moduleId && $currentPosition?.lessonId === lesson.id}
|
|
45
|
+
{@const locked = lockedLessons.has(lesson.id)}
|
|
32
46
|
<li>
|
|
33
47
|
<button
|
|
34
|
-
onclick={() =>
|
|
48
|
+
onclick={() => handleClick(lesson.id)}
|
|
35
49
|
class="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors
|
|
36
|
-
{
|
|
37
|
-
? '
|
|
38
|
-
:
|
|
50
|
+
{locked
|
|
51
|
+
? 'cursor-not-allowed text-gray-300'
|
|
52
|
+
: isActive
|
|
53
|
+
? 'bg-blue-100 font-medium text-blue-700'
|
|
54
|
+
: 'text-gray-700 hover:bg-gray-100'}"
|
|
55
|
+
aria-disabled={locked}
|
|
39
56
|
>
|
|
40
|
-
<span class="shrink-0 text-xs {statusClass(lesson.id)}"
|
|
57
|
+
<span class="shrink-0 text-xs {locked ? 'text-gray-300' : statusClass(lesson.id)}"
|
|
58
|
+
>{statusIcon(lesson.id)}</span
|
|
59
|
+
>
|
|
41
60
|
<span class="truncate">{lesson.title}</span>
|
|
42
61
|
</button>
|
|
43
62
|
</li>
|
learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { getLessonProgress, markLessonComplete, markLessonInProgress } from '$lib/db/index.js';
|
|
4
|
+
import { curriculum } from '$lib/stores/curriculum.js';
|
|
5
|
+
import { invalidateProgress } from '$lib/stores/progress.js';
|
|
6
|
+
import type { Lesson, QuizScore } from '$lib/types/index.js';
|
|
7
|
+
import ContentBlock from './ContentBlock.svelte';
|
|
8
|
+
import Navigation from './Navigation.svelte';
|
|
9
|
+
import { onMount } from 'svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
lesson: Lesson;
|
|
13
|
+
moduleId: string;
|
|
14
|
+
}
|
|
15
|
+
let { lesson, moduleId }: Props = $props();
|
|
16
|
+
|
|
17
|
+
let allBlocksComplete = $state(false);
|
|
18
|
+
let completedBlocks = $state(new Set<number>());
|
|
19
|
+
|
|
20
|
+
const lessonComplete = $derived(
|
|
21
|
+
allBlocksComplete || completedBlocks.size === lesson.content_blocks.length
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
onMount(async () => {
|
|
25
|
+
// Zero-block edge case
|
|
26
|
+
if (lesson.content_blocks.length === 0) {
|
|
27
|
+
allBlocksComplete = true;
|
|
28
|
+
await markLessonComplete(moduleId, lesson.id);
|
|
29
|
+
await invalidateProgress($curriculum);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Revisit: if lesson is already complete, pre-fill so nav is active
|
|
34
|
+
const existing = await getLessonProgress(moduleId, lesson.id);
|
|
35
|
+
if (existing?.status === 'complete') {
|
|
36
|
+
allBlocksComplete = true;
|
|
37
|
+
} else {
|
|
38
|
+
await markLessonInProgress(moduleId, lesson.id);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function handleBlockComplete(blockIndex: number) {
|
|
43
|
+
if (allBlocksComplete) return;
|
|
44
|
+
completedBlocks.add(blockIndex);
|
|
45
|
+
completedBlocks = new Set(completedBlocks);
|
|
46
|
+
if (completedBlocks.size === lesson.content_blocks.length) {
|
|
47
|
+
await markLessonComplete(moduleId, lesson.id);
|
|
48
|
+
// Refreshing the progress store is enough to drive the
|
|
49
|
+
// `unlock_module_on_complete` cascade: locking utilities in
|
|
50
|
+
// `$lib/utils/locking.ts` re-derive sibling-optional and
|
|
51
|
+
// next-module-unlocked state from the new `complete` status —
|
|
52
|
+
// no additional DB write or extra invalidation is required.
|
|
53
|
+
await invalidateProgress($curriculum);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleQuizComplete(_score: QuizScore) {
|
|
58
|
+
// Score already persisted by QuizBlock; could trigger further logic here
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<article class="mx-auto max-w-3xl space-y-8 py-6">
|
|
63
|
+
<header>
|
|
64
|
+
<h1 class="text-2xl font-bold text-gray-900">{lesson.title}</h1>
|
|
65
|
+
</header>
|
|
66
|
+
|
|
67
|
+
<div class="space-y-8">
|
|
68
|
+
{#each lesson.content_blocks as block, i (i)}
|
|
69
|
+
<section>
|
|
70
|
+
<ContentBlock
|
|
71
|
+
{block}
|
|
72
|
+
blockIndex={i}
|
|
73
|
+
onblockcomplete={handleBlockComplete}
|
|
74
|
+
onquizcomplete={handleQuizComplete}
|
|
75
|
+
/>
|
|
76
|
+
</section>
|
|
77
|
+
{/each}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<Navigation disabled={!lessonComplete} />
|
|
81
|
+
</article>
|
learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createBlockTracker } from './lesson-view.helpers.js';
|
|
5
|
+
|
|
6
|
+
describe('LessonView block tracking (via createBlockTracker)', () => {
|
|
7
|
+
it('all blocks complete → returns true on final markBlockComplete', () => {
|
|
8
|
+
const tracker = createBlockTracker(3);
|
|
9
|
+
|
|
10
|
+
expect(tracker.markBlockComplete(0)).toBe(false);
|
|
11
|
+
expect(tracker.markBlockComplete(1)).toBe(false);
|
|
12
|
+
expect(tracker.markBlockComplete(2)).toBe(true);
|
|
13
|
+
expect(tracker.allComplete).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('button disabled until all blocks done (allComplete is false)', () => {
|
|
17
|
+
const tracker = createBlockTracker(2);
|
|
18
|
+
|
|
19
|
+
expect(tracker.allComplete).toBe(false);
|
|
20
|
+
tracker.markBlockComplete(0);
|
|
21
|
+
expect(tracker.allComplete).toBe(false);
|
|
22
|
+
tracker.markBlockComplete(1);
|
|
23
|
+
expect(tracker.allComplete).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('pre-fills set when lesson is already complete (revisit)', () => {
|
|
27
|
+
const tracker = createBlockTracker(5, true);
|
|
28
|
+
|
|
29
|
+
// Immediately complete — no engagement required
|
|
30
|
+
expect(tracker.allComplete).toBe(true);
|
|
31
|
+
expect(tracker.completedCount).toBe(5);
|
|
32
|
+
expect(tracker.markBlockComplete(0)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('zero-block lesson is immediately complete', () => {
|
|
36
|
+
const tracker = createBlockTracker(0);
|
|
37
|
+
|
|
38
|
+
expect(tracker.allComplete).toBe(true);
|
|
39
|
+
expect(tracker.completedCount).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('duplicate markBlockComplete calls are idempotent', () => {
|
|
43
|
+
const tracker = createBlockTracker(2);
|
|
44
|
+
|
|
45
|
+
tracker.markBlockComplete(0);
|
|
46
|
+
tracker.markBlockComplete(0);
|
|
47
|
+
expect(tracker.completedCount).toBe(1);
|
|
48
|
+
expect(tracker.allComplete).toBe(false);
|
|
49
|
+
|
|
50
|
+
tracker.markBlockComplete(1);
|
|
51
|
+
expect(tracker.allComplete).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('completedCount tracks incremental progress', () => {
|
|
55
|
+
const tracker = createBlockTracker(4);
|
|
56
|
+
|
|
57
|
+
expect(tracker.completedCount).toBe(0);
|
|
58
|
+
tracker.markBlockComplete(2);
|
|
59
|
+
expect(tracker.completedCount).toBe(1);
|
|
60
|
+
tracker.markBlockComplete(0);
|
|
61
|
+
expect(tracker.completedCount).toBe(2);
|
|
62
|
+
tracker.markBlockComplete(3);
|
|
63
|
+
expect(tracker.completedCount).toBe(3);
|
|
64
|
+
tracker.markBlockComplete(1);
|
|
65
|
+
expect(tracker.completedCount).toBe(4);
|
|
66
|
+
expect(tracker.allComplete).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|