learningfoundry 0.45.0__tar.gz → 0.49.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.45.0 → learningfoundry-0.49.0}/CHANGELOG.md +35 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/PKG-INFO +1 -1
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/pyproject.toml +1 -1
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/__init__.py +1 -1
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +31 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +30 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +24 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +32 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +27 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +27 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/playwright.config.ts +27 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
- {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +50 -1
- {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
- {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/Navigation.svelte +11 -8
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +46 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +95 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +49 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +15 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +40 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +43 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +52 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +1 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +44 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/db/progress.ts +20 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +11 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +71 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +26 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +6 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +11 -2
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/Navigation.svelte +11 -8
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/ResetCourseButton.svelte +46 -0
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +95 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/TextBlock.test.ts +127 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/lesson-view.helpers.ts +63 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/module-list.test.ts +105 -0
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/navigation.helpers.ts +43 -0
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/navigation.test.ts +52 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/index.ts +1 -0
- learningfoundry-0.49.0/sveltekit_template/src/lib/db/progress.test.ts +44 -0
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/progress.ts +20 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/curriculum.ts +10 -0
- learningfoundry-0.49.0/sveltekit_template/src/lib/utils/progress.test.ts +71 -0
- learningfoundry-0.49.0/sveltekit_template/src/lib/utils/progress.ts +26 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+layout.svelte +6 -0
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
- learningfoundry-0.49.0/sveltekit_template/src/routes/layout.test.ts +102 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/.gitignore +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/LICENSE +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/README.md +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/asset_resolver.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/cli.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/config.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/generator.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/pipeline.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/resolver.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/schema_v1.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+page.svelte +0 -0
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.49.0] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Finish on the last lesson now clears the active-lesson highlight and collapses the previously expanded sidebar module.** When `Navigation.goNext()` finds no next lesson it now sets `currentPosition` to `null` *before* `goto('/')` so the sidebar's auto-expand effect sees the null transition; `computeAutoExpand` was extended to emit a reset (`{expandedModuleId: null, lastAutoExpandedModuleId: null}`) when position clears after a prior auto-expand. Result: landing on the dashboard after Finish shows no module expanded and no lesson row carrying the active highlight, instead of leaving the previously focused lesson visually marked as the learner's current location. The I.f manual-toggle preservation behavior is unchanged (the reset only fires on the position-cleared transition).
|
|
15
|
+
|
|
16
|
+
## [0.48.0] - 2026-05-01
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **TextBlock completion now requires the bottom of the block to be in view, not just any portion.** `TextBlock.svelte` renders a zero-size `<div data-textblock-end aria-hidden="true">` sentinel at the end of the rendered markdown and observes that element rather than the wrapper. A tall lesson can no longer be marked complete simply because the top of the text was on screen on initial render — the learner must scroll until the sentinel is in the viewport for the full 1-second debounce window. The `IntersectionObserver` debounce, threshold (`0.1`), and single-fire `fired` guard are unchanged. New vitest cases cover the "tall block, sentinel never intersects" and "scrolled into view → fires 1 s later" branches.
|
|
21
|
+
|
|
22
|
+
## [0.47.0] - 2026-05-01
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Reset course button.** New `ResetCourseButton.svelte` pinned at the bottom of the sidebar (`mt-auto`). Disabled until any progress exists in the curriculum (any `lesson_progress` row whose status is not `not_started`); reactive activation via the existing `progressStore`. Clicking opens a `window.confirm` dialog; on accept it calls the new `resetProgress()` DB op (single-transaction `DELETE FROM lesson_progress; quiz_scores; exercise_status`), clears `currentPosition`, refreshes the progress store, and routes to `/`. Pure helpers `hasAnyProgress` (in `$lib/utils/progress.ts`) and the new `resetProgress` DB op are independently unit-tested.
|
|
27
|
+
|
|
28
|
+
## [0.46.0] - 2026-05-01
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **Lesson navigation routing.** Sidebar lesson clicks, the Next/Finish button, and dashboard "Start module / Continue" buttons now call `goto()` from `$app/navigation` directly instead of going through the curriculum store helper. The previous flow updated `currentPosition` (and therefore the sidebar highlight) but left the URL untouched, so the lesson route was never re-mounted: `markLessonInProgress` ran only on the first lesson reached by direct URL, the sidebar checkmarks never updated, and the curriculum/module progress bars stayed at zero across the session. Components now route via `Navigation.svelte`, `LessonList.svelte`, `ProgressDashboard.svelte` → `goto('/${moduleId}/${lessonId}')`.
|
|
33
|
+
- **Sticky LessonView state across navigations.** The dynamic lesson route now wraps `<LessonView>` in `{#key \`${moduleId}/${lessonId}\`}` so the subtree tears down and re-mounts whenever either route param changes — guaranteeing fresh `allBlocksComplete` / `completedBlocks` state and a re-run of the on-mount progress check (so revisiting a previously-completed lesson activates Next/Finish immediately).
|
|
34
|
+
- **Stale video iframe across consecutive video lessons.** `LessonView`'s `{#each lesson.content_blocks}` now uses a stable identity key derived from `block.ref` or `block.content.url` (falling back to `${type}-${index}`); previously, two consecutive lessons each with a `video` block reused the same `<VideoBlock>` instance and its iframe player, leaving the previous lesson's video on screen. `VideoBlock.svelte` additionally tracks `content.url` via `$effect` and tears down / recreates its YouTube player whenever the URL changes, as belt-and-suspenders coverage.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Playwright e2e harness.** New `e2e/` directory with three regression specs (`navigation.spec.ts`, `progress.spec.ts`, `video.spec.ts`) covering the FR-P9/FR-P10 lifecycle invariants that vitest cannot exercise (because vitest mocks `$app/navigation`). New `pnpm e2e` script and `playwright.config.ts` driving `pnpm preview` against the built static site. The smoke test runs `pnpm e2e` after `pnpm build` and skips gracefully if Playwright browsers aren't installed locally; CI installs them via `pnpm exec playwright install chromium`.
|
|
39
|
+
- **Vitest navigation regression coverage.** New `navigation.helpers.ts` (`resolveGoNext`, `resolveGoPrev`, `lessonHref`) and `navigation.test.ts` lock down the routing decisions made by Next/Finish, Previous, and lesson rows. New `contentBlockKey` helper (and tests) verifies the FR-P10 stable-identity convention used by `{#each}`.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- `navigateTo` in `$lib/stores/curriculum.ts` is now documented as **internal route-sync only — UI code must use `goto` directly**. The function continues to set `currentPosition` for use by the dynamic lesson route's URL→store `$effect`; UI code (sidebar, dashboard, Next/Finish) routes via `goto()` so SvelteKit's full navigation lifecycle (page params, scroll restoration, `{#key}` re-mount) fires predictably.
|
|
44
|
+
|
|
10
45
|
## [0.45.0] - 2026-04-30
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learningfoundry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.49.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.49.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,31 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P14 regression coverage: Finish on the last lesson clears the
|
|
5
|
+
// active-lesson highlight and collapses the previously expanded sidebar
|
|
6
|
+
// module, so the dashboard re-displays in a clean state.
|
|
7
|
+
//
|
|
8
|
+
// Reaching the last lesson and force-completing it requires viewport
|
|
9
|
+
// scroll for the FR-P13 sentinel; pending a richer e2e fixture, this
|
|
10
|
+
// spec validates the underlying invariant that the curriculum-title
|
|
11
|
+
// link still works and that landing at `/` shows no sidebar lesson row
|
|
12
|
+
// with the active highlight class.
|
|
13
|
+
import { expect, test } from '@playwright/test';
|
|
14
|
+
|
|
15
|
+
test.describe('Finish-on-last-lesson sidebar state', () => {
|
|
16
|
+
test('dashboard renders no active lesson highlight', async ({ page }) => {
|
|
17
|
+
await page.goto('/');
|
|
18
|
+
// The active-lesson highlight class is `bg-blue-100 text-blue-700` on
|
|
19
|
+
// a lesson row. On the dashboard with no current position, no lesson
|
|
20
|
+
// row should carry it.
|
|
21
|
+
const active = page.locator('aside nav ul ul button.bg-blue-100');
|
|
22
|
+
await expect(active).toHaveCount(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('dashboard renders no expanded module by default', async ({ page }) => {
|
|
26
|
+
await page.goto('/');
|
|
27
|
+
// Module list panels render a nested `<ul>` only when expanded.
|
|
28
|
+
const expandedPanels = page.locator('aside nav > ul > li > div > ul');
|
|
29
|
+
await expect(expandedPanels).toHaveCount(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P9 regression coverage: in-app navigation must update the URL.
|
|
5
|
+
// Before v0.46.0 the sidebar / Next button updated `currentPosition` but
|
|
6
|
+
// not the route, so `LessonView` was never re-mounted on lesson change.
|
|
7
|
+
import { expect, test } from '@playwright/test';
|
|
8
|
+
|
|
9
|
+
test.describe('lesson navigation routing', () => {
|
|
10
|
+
test('sidebar lesson click updates URL', async ({ page }) => {
|
|
11
|
+
await page.goto('/');
|
|
12
|
+
// Open the first module by clicking its header in the sidebar.
|
|
13
|
+
const firstModuleHeader = page.locator('aside nav button').first();
|
|
14
|
+
await firstModuleHeader.click();
|
|
15
|
+
|
|
16
|
+
// Click the first lesson row in the now-expanded module.
|
|
17
|
+
const firstLessonRow = page.locator('aside nav ul ul button').first();
|
|
18
|
+
await firstLessonRow.click();
|
|
19
|
+
|
|
20
|
+
// URL must reflect the click; before v0.46.0 it stayed at "/".
|
|
21
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('dashboard "Start module" deep-links into a lesson', async ({ page }) => {
|
|
25
|
+
await page.goto('/');
|
|
26
|
+
const startBtn = page.getByRole('button', { name: /start module|continue/i }).first();
|
|
27
|
+
await startBtn.click();
|
|
28
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P9 regression: progress reactivity. Reaching a lesson page must mark
|
|
5
|
+
// it `in_progress` and the sidebar status icon must reflect that on the
|
|
6
|
+
// next sidebar render — without a page reload.
|
|
7
|
+
import { expect, test } from '@playwright/test';
|
|
8
|
+
|
|
9
|
+
test.describe('progress reactivity', () => {
|
|
10
|
+
test('navigating into a lesson marks it in_progress in the sidebar', async ({ page }) => {
|
|
11
|
+
await page.goto('/');
|
|
12
|
+
|
|
13
|
+
// Expand the first module and click into its first lesson.
|
|
14
|
+
await page.locator('aside nav button').first().click();
|
|
15
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
16
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
17
|
+
|
|
18
|
+
// Sidebar status icons: ○ = not_started, … = in_progress, ✓ = complete.
|
|
19
|
+
// After landing on the lesson page, the active row should not still
|
|
20
|
+
// show ○ (regression check; the previous bug left status forever ○).
|
|
21
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
22
|
+
await expect(activeIcon).not.toHaveText('○');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P12 regression coverage: Reset course button.
|
|
5
|
+
//
|
|
6
|
+
// The full round-trip from the story task list (complete a lesson →
|
|
7
|
+
// button enables → reset → checkmark gone, % returns to 0, dashboard
|
|
8
|
+
// reads "0 of N completed", URL is `/`) requires either a tall scroll
|
|
9
|
+
// to satisfy the FR-P13 sentinel or a fixture without that constraint.
|
|
10
|
+
// Until the e2e fixture story (FR-P11 fixture file) lands, these tests
|
|
11
|
+
// validate the smaller invariants that don't require completion:
|
|
12
|
+
// - The button exists in the DOM at the bottom of the sidebar.
|
|
13
|
+
// - On a fresh load (no progress), it is disabled.
|
|
14
|
+
// - Clicking it while disabled is a no-op (URL unchanged).
|
|
15
|
+
import { expect, test } from '@playwright/test';
|
|
16
|
+
|
|
17
|
+
test.describe('Reset course button', () => {
|
|
18
|
+
test('renders disabled when no progress exists', async ({ page }) => {
|
|
19
|
+
await page.goto('/');
|
|
20
|
+
const btn = page.getByRole('button', { name: /Reset course progress/i });
|
|
21
|
+
await expect(btn).toBeVisible();
|
|
22
|
+
await expect(btn).toBeDisabled();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('clicking the disabled button does not navigate', async ({ page }) => {
|
|
26
|
+
await page.goto('/');
|
|
27
|
+
const btn = page.getByRole('button', { name: /Reset course progress/i });
|
|
28
|
+
await btn.click({ force: true }).catch(() => {});
|
|
29
|
+
// Still on the dashboard.
|
|
30
|
+
expect(new URL(page.url()).pathname).toBe('/');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P13 regression coverage: TextBlock completion requires the
|
|
5
|
+
// end-of-block sentinel to be in view for 1 s. A tall text block where
|
|
6
|
+
// the learner never scrolls past the top must NOT mark the lesson
|
|
7
|
+
// complete — only scrolling to the bottom should.
|
|
8
|
+
//
|
|
9
|
+
// The smoke fixture's text content may not always exceed viewport
|
|
10
|
+
// height (curriculum authors control content); we verify the sentinel
|
|
11
|
+
// element is present on the lesson page and is positioned at the end
|
|
12
|
+
// of the rendered markdown, which is the structural invariant FR-P13
|
|
13
|
+
// depends on.
|
|
14
|
+
import { expect, test } from '@playwright/test';
|
|
15
|
+
|
|
16
|
+
test.describe('TextBlock end-of-block sentinel', () => {
|
|
17
|
+
test('sentinel element is rendered after the markdown', async ({ page }) => {
|
|
18
|
+
await page.goto('/');
|
|
19
|
+
await page.locator('aside nav button').first().click();
|
|
20
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
21
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
22
|
+
|
|
23
|
+
// Wait for at least one TextBlock to render its prose and sentinel.
|
|
24
|
+
const sentinel = page.locator('[data-textblock-end]').first();
|
|
25
|
+
await expect(sentinel).toBeAttached();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P10 regression coverage: when navigating between two lessons that
|
|
5
|
+
// each contain a video block, only the new lesson's iframe should be
|
|
6
|
+
// present — the previous player must be destroyed. The {#key} wrapper
|
|
7
|
+
// in `[module]/[lesson]/+page.svelte` plus the stable block key in
|
|
8
|
+
// `LessonView` enforce this.
|
|
9
|
+
import { expect, test } from '@playwright/test';
|
|
10
|
+
|
|
11
|
+
test.describe('video block lifecycle', () => {
|
|
12
|
+
test('lesson page renders at most one YouTube iframe per video block', async ({ page }) => {
|
|
13
|
+
await page.goto('/');
|
|
14
|
+
await page.locator('aside nav button').first().click();
|
|
15
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
16
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
17
|
+
|
|
18
|
+
// Wait briefly for the YouTube IFrame API to upgrade the placeholder
|
|
19
|
+
// `<div id="yt-player-…">` into an `<iframe>`. If the API is blocked
|
|
20
|
+
// (no network in CI), there will be zero iframes — that's still a
|
|
21
|
+
// pass for "no leaked iframes from a prior lesson", which is what
|
|
22
|
+
// the regression cares about.
|
|
23
|
+
await page.waitForTimeout(2000);
|
|
24
|
+
const iframes = await page.locator('iframe[src*="youtube"]').count();
|
|
25
|
+
expect(iframes).toBeLessThanOrEqual(1);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
10
10
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
11
11
|
"test": "vitest run",
|
|
12
|
+
"e2e": "playwright test",
|
|
12
13
|
"postinstall": "node -e \"const fs=require('fs'); fs.mkdirSync('static',{recursive:true}); fs.copyFileSync(require.resolve('sql.js/dist/sql-wasm.wasm'),'static/sql-wasm.wasm')\""
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"svelte": "^5.0.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
25
|
+
"@playwright/test": "^1.48.0",
|
|
24
26
|
"@sveltejs/adapter-static": "^3.0.0",
|
|
25
27
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
26
28
|
"@tailwindcss/typography": "^0.5.16",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
4
|
+
|
|
5
|
+
const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 4173);
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
testDir: './e2e',
|
|
9
|
+
fullyParallel: false,
|
|
10
|
+
reporter: [['list']],
|
|
11
|
+
use: {
|
|
12
|
+
baseURL: `http://localhost:${PORT}`,
|
|
13
|
+
trace: 'retain-on-failure'
|
|
14
|
+
},
|
|
15
|
+
webServer: {
|
|
16
|
+
command: `pnpm preview --port ${PORT} --strictPort`,
|
|
17
|
+
port: PORT,
|
|
18
|
+
reuseExistingServer: !process.env.CI,
|
|
19
|
+
timeout: 60_000
|
|
20
|
+
},
|
|
21
|
+
projects: [
|
|
22
|
+
{
|
|
23
|
+
name: 'chromium',
|
|
24
|
+
use: { ...devices['Desktop Chrome'] }
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
|
-
import {
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { currentPosition } from '$lib/stores/curriculum.js';
|
|
4
5
|
import type { Lesson, LessonProgress } from '$lib/types/index.js';
|
|
5
6
|
import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
|
|
7
|
+
import { lessonHref } from './navigation.helpers.js';
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
moduleId: string;
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
|
|
35
37
|
function handleClick(lessonId: string) {
|
|
36
38
|
if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
|
|
37
|
-
|
|
39
|
+
void goto(lessonHref(moduleId, lessonId));
|
|
38
40
|
}
|
|
39
41
|
</script>
|
|
40
42
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Lesson, QuizScore } from '$lib/types/index.js';
|
|
7
7
|
import ContentBlock from './ContentBlock.svelte';
|
|
8
8
|
import Navigation from './Navigation.svelte';
|
|
9
|
+
import { contentBlockKey } from './lesson-view.helpers.js';
|
|
9
10
|
import { onMount } from 'svelte';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
</header>
|
|
66
67
|
|
|
67
68
|
<div class="space-y-8">
|
|
68
|
-
{#each lesson.content_blocks as block, i (i)}
|
|
69
|
+
{#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
|
|
69
70
|
<section>
|
|
70
71
|
<ContentBlock
|
|
71
72
|
{block}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright 2026 Pointmatic
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { createBlockTracker } from './lesson-view.helpers.js';
|
|
4
|
+
import { contentBlockKey, createBlockTracker } from './lesson-view.helpers.js';
|
|
5
|
+
import type { ContentBlock } from '$lib/types/index.js';
|
|
5
6
|
|
|
6
7
|
describe('LessonView block tracking (via createBlockTracker)', () => {
|
|
7
8
|
it('all blocks complete → returns true on final markBlockComplete', () => {
|
|
@@ -66,3 +67,51 @@ describe('LessonView block tracking (via createBlockTracker)', () => {
|
|
|
66
67
|
expect(tracker.allComplete).toBe(true);
|
|
67
68
|
});
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
describe('contentBlockKey (FR-P10 stable identity)', () => {
|
|
72
|
+
it('uses ref when present', () => {
|
|
73
|
+
const block = {
|
|
74
|
+
type: 'text',
|
|
75
|
+
source: null,
|
|
76
|
+
ref: 'content/mod-01/lesson-01.md',
|
|
77
|
+
content: { markdown: '', path: '' }
|
|
78
|
+
} as ContentBlock;
|
|
79
|
+
expect(contentBlockKey(block, 0)).toBe('text:content/mod-01/lesson-01.md');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses content.url for video blocks (no ref)', () => {
|
|
83
|
+
const block = {
|
|
84
|
+
type: 'video',
|
|
85
|
+
source: null,
|
|
86
|
+
ref: null,
|
|
87
|
+
content: { url: 'https://www.youtube.com/watch?v=abc' }
|
|
88
|
+
} as ContentBlock;
|
|
89
|
+
expect(contentBlockKey(block, 0)).toBe('video:https://www.youtube.com/watch?v=abc');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('falls back to type + index when neither ref nor url is present', () => {
|
|
93
|
+
const block = {
|
|
94
|
+
type: 'visualization',
|
|
95
|
+
source: null,
|
|
96
|
+
ref: null,
|
|
97
|
+
content: {}
|
|
98
|
+
} as unknown as ContentBlock;
|
|
99
|
+
expect(contentBlockKey(block, 2)).toBe('visualization-2');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('two video blocks with different URLs produce different keys', () => {
|
|
103
|
+
const a = {
|
|
104
|
+
type: 'video',
|
|
105
|
+
source: null,
|
|
106
|
+
ref: null,
|
|
107
|
+
content: { url: 'https://youtu.be/AAA' }
|
|
108
|
+
} as ContentBlock;
|
|
109
|
+
const b = {
|
|
110
|
+
type: 'video',
|
|
111
|
+
source: null,
|
|
112
|
+
ref: null,
|
|
113
|
+
content: { url: 'https://youtu.be/BBB' }
|
|
114
|
+
} as ContentBlock;
|
|
115
|
+
expect(contentBlockKey(a, 0)).not.toBe(contentBlockKey(b, 0));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { currentPosition } from '$lib/stores/curriculum.js';
|
|
4
4
|
import type { Curriculum, Module, ModuleProgress } from '$lib/types/index.js';
|
|
5
5
|
import { getOptionalLessons, lockedLessonIds } from '$lib/utils/locking.js';
|
|
6
|
-
import { resolveModuleHeaderClick } from './module-list.helpers.js';
|
|
6
|
+
import { computeAutoExpand, resolveModuleHeaderClick } from './module-list.helpers.js';
|
|
7
7
|
import LessonList from './LessonList.svelte';
|
|
8
8
|
import ProgressBar from './ProgressBar.svelte';
|
|
9
9
|
import Lock from 'lucide-svelte/icons/lock';
|
|
@@ -42,12 +42,15 @@
|
|
|
42
42
|
// Auto-expand the module containing the current lesson.
|
|
43
43
|
// Only fire when `currentPosition.moduleId` changes to a *new* value;
|
|
44
44
|
// `lastAutoExpandedModuleId` breaks the self-dependency that previously
|
|
45
|
-
// caused manual toggles to revert immediately.
|
|
45
|
+
// caused manual toggles to revert immediately. When the position is
|
|
46
|
+
// cleared (Finish on the last lesson, FR-P14), collapse the previously
|
|
47
|
+
// expanded module so the dashboard sidebar starts from a clean slate.
|
|
46
48
|
$effect(() => {
|
|
47
49
|
const pos = $currentPosition;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const next = computeAutoExpand(pos?.moduleId ?? null, lastAutoExpandedModuleId);
|
|
51
|
+
if (next) {
|
|
52
|
+
expandedModuleId = next.expandedModuleId;
|
|
53
|
+
lastAutoExpandedModuleId = next.lastAutoExpandedModuleId;
|
|
51
54
|
}
|
|
52
55
|
});
|
|
53
56
|
</script>
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import { goto } from '$app/navigation';
|
|
4
4
|
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
|
5
|
-
import { nextLesson, previousLesson
|
|
5
|
+
import { currentPosition, nextLesson, previousLesson } from '$lib/stores/curriculum.js';
|
|
6
|
+
import { resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
disabled?: boolean;
|
|
@@ -13,16 +14,18 @@
|
|
|
13
14
|
const next = $derived($nextLesson);
|
|
14
15
|
|
|
15
16
|
function goNext() {
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
const action = resolveGoNext(disabled, next);
|
|
18
|
+
if (action.kind === 'noop') return;
|
|
19
|
+
// Clear the position *before* `goto` so the sidebar's auto-expand
|
|
20
|
+
// effect sees the null transition and collapses the previously
|
|
21
|
+
// expanded module before the route change settles.
|
|
22
|
+
if (action.clearPosition) currentPosition.set(null);
|
|
23
|
+
void goto(action.url);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
function goPrev() {
|
|
25
|
-
|
|
27
|
+
const action = resolveGoPrev(prev);
|
|
28
|
+
if (action.kind === 'goto') void goto(action.url);
|
|
26
29
|
}
|
|
27
30
|
</script>
|
|
28
31
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
|
-
import {
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
4
|
import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
|
|
5
5
|
import { getOptionalLessons, isModuleComplete } from '$lib/utils/locking.js';
|
|
6
6
|
import ProgressBar from './ProgressBar.svelte';
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
(l) => !optional.has(l.id) && mp?.lessons[l.id]?.status !== 'complete'
|
|
46
46
|
);
|
|
47
47
|
const target = firstIncomplete ?? mod.lessons[0];
|
|
48
|
-
if (target)
|
|
48
|
+
if (target) void goto(`/${mod.id}/${target.id}`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const totalLessons = $derived(modules.reduce((n, m) => n + m.lessons.length, 0));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { resetProgress } from '$lib/db/index.js';
|
|
5
|
+
import { currentPosition, curriculum } from '$lib/stores/curriculum.js';
|
|
6
|
+
import { invalidateProgress } from '$lib/stores/progress.js';
|
|
7
|
+
import { RotateCcw } from 'lucide-svelte';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Override for unit tests; defaults to `window.confirm`. */
|
|
12
|
+
confirmFn?: (message: string) => boolean;
|
|
13
|
+
}
|
|
14
|
+
let {
|
|
15
|
+
disabled = false,
|
|
16
|
+
confirmFn = (msg: string) => window.confirm(msg)
|
|
17
|
+
}: Props = $props();
|
|
18
|
+
|
|
19
|
+
const PROMPT = 'Reset all progress for this curriculum? This cannot be undone.';
|
|
20
|
+
|
|
21
|
+
async function handleClick() {
|
|
22
|
+
if (disabled) return;
|
|
23
|
+
if (!confirmFn(PROMPT)) return;
|
|
24
|
+
await resetProgress();
|
|
25
|
+
// Clearing the position triggers the FR-P14 sidebar collapse path
|
|
26
|
+
// in `ModuleList`'s auto-expand `$effect` once Story I.n ships;
|
|
27
|
+
// pre-I.n it is a harmless no-op.
|
|
28
|
+
currentPosition.set(null);
|
|
29
|
+
await invalidateProgress($curriculum);
|
|
30
|
+
await goto('/');
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onclick={handleClick}
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
aria-disabled={disabled}
|
|
39
|
+
class="flex w-full items-center justify-center gap-2 rounded border border-transparent px-3 py-2 text-xs font-medium transition-colors
|
|
40
|
+
{disabled
|
|
41
|
+
? 'cursor-not-allowed text-gray-300'
|
|
42
|
+
: 'text-red-600 hover:bg-red-50'}"
|
|
43
|
+
>
|
|
44
|
+
<RotateCcw size={14} aria-hidden="true" />
|
|
45
|
+
Reset course progress
|
|
46
|
+
</button>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// Logic-level coverage for the Reset Course button. We don't mount the
|
|
5
|
+
// Svelte component (the existing test convention is to extract testable
|
|
6
|
+
// logic instead). Instead we validate the click-handler contract:
|
|
7
|
+
//
|
|
8
|
+
// - disabled → no DB writes, no goto
|
|
9
|
+
// - enabled + cancelled confirm → no DB writes, no goto
|
|
10
|
+
// - enabled + accepted confirm → resetProgress + invalidateProgress + goto('/')
|
|
11
|
+
//
|
|
12
|
+
// These three branches are the meaningful failure modes; the Svelte
|
|
13
|
+
// rendering is a thin shell over them.
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
const { resetMock, invalidateMock, gotoMock, setPosition } = vi.hoisted(() => ({
|
|
17
|
+
resetMock: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
invalidateMock: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
gotoMock: vi.fn(),
|
|
20
|
+
setPosition: vi.fn()
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('$app/navigation', () => ({ goto: gotoMock }));
|
|
24
|
+
vi.mock('$lib/db/index.js', () => ({ resetProgress: resetMock }));
|
|
25
|
+
vi.mock('$lib/stores/curriculum.js', () => ({
|
|
26
|
+
curriculum: { subscribe: (fn: (v: null) => void) => (fn(null), () => {}) },
|
|
27
|
+
currentPosition: { set: setPosition }
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('$lib/stores/progress.js', () => ({ invalidateProgress: invalidateMock }));
|
|
30
|
+
|
|
31
|
+
const PROMPT = 'Reset all progress for this curriculum? This cannot be undone.';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inline copy of the click-handler logic in `ResetCourseButton.svelte`.
|
|
35
|
+
* Kept in lockstep with the component; if you change the component, mirror
|
|
36
|
+
* the change here.
|
|
37
|
+
*/
|
|
38
|
+
async function clickHandler(
|
|
39
|
+
disabled: boolean,
|
|
40
|
+
confirmFn: (msg: string) => boolean
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const { resetProgress } = await import('$lib/db/index.js');
|
|
43
|
+
const { currentPosition, curriculum } = await import('$lib/stores/curriculum.js');
|
|
44
|
+
const { invalidateProgress } = await import('$lib/stores/progress.js');
|
|
45
|
+
const { goto } = await import('$app/navigation');
|
|
46
|
+
if (disabled) return;
|
|
47
|
+
if (!confirmFn(PROMPT)) return;
|
|
48
|
+
await resetProgress();
|
|
49
|
+
currentPosition.set(null);
|
|
50
|
+
let cur = null;
|
|
51
|
+
curriculum.subscribe((v: unknown) => (cur = v as null))();
|
|
52
|
+
await invalidateProgress(cur);
|
|
53
|
+
await goto('/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('ResetCourseButton click handler', () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
resetMock.mockClear();
|
|
59
|
+
invalidateMock.mockClear();
|
|
60
|
+
gotoMock.mockClear();
|
|
61
|
+
setPosition.mockClear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('disabled → no reset, no navigation', async () => {
|
|
69
|
+
const confirmFn = vi.fn(() => true);
|
|
70
|
+
await clickHandler(true, confirmFn);
|
|
71
|
+
expect(confirmFn).not.toHaveBeenCalled();
|
|
72
|
+
expect(resetMock).not.toHaveBeenCalled();
|
|
73
|
+
expect(setPosition).not.toHaveBeenCalled();
|
|
74
|
+
expect(invalidateMock).not.toHaveBeenCalled();
|
|
75
|
+
expect(gotoMock).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('enabled + cancelled confirm → no reset, no navigation', async () => {
|
|
79
|
+
const confirmFn = vi.fn(() => false);
|
|
80
|
+
await clickHandler(false, confirmFn);
|
|
81
|
+
expect(confirmFn).toHaveBeenCalledOnce();
|
|
82
|
+
expect(resetMock).not.toHaveBeenCalled();
|
|
83
|
+
expect(setPosition).not.toHaveBeenCalled();
|
|
84
|
+
expect(gotoMock).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('enabled + accepted confirm → resets, clears position, invalidates, navigates home', async () => {
|
|
88
|
+
const confirmFn = vi.fn(() => true);
|
|
89
|
+
await clickHandler(false, confirmFn);
|
|
90
|
+
expect(resetMock).toHaveBeenCalledOnce();
|
|
91
|
+
expect(setPosition).toHaveBeenCalledWith(null);
|
|
92
|
+
expect(invalidateMock).toHaveBeenCalledOnce();
|
|
93
|
+
expect(gotoMock).toHaveBeenCalledWith('/');
|
|
94
|
+
});
|
|
95
|
+
});
|