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.
Files changed (128) hide show
  1. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/CHANGELOG.md +35 -0
  2. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/PKG-INFO +1 -1
  3. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/pyproject.toml +1 -1
  4. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/__init__.py +1 -1
  5. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +31 -0
  6. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +30 -0
  7. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +24 -0
  8. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +32 -0
  9. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +27 -0
  10. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +27 -0
  11. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
  12. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/playwright.config.ts +27 -0
  13. {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
  14. {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
  15. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +50 -1
  16. {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
  17. {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/Navigation.svelte +11 -8
  18. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
  19. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +46 -0
  20. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +95 -0
  21. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
  22. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +49 -0
  23. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
  24. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +15 -0
  25. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
  26. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +40 -0
  27. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +43 -0
  28. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +52 -0
  29. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +1 -0
  30. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +44 -0
  31. {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/db/progress.ts +20 -0
  32. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +11 -0
  33. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +71 -0
  34. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +26 -0
  35. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +6 -0
  36. {learningfoundry-0.45.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
  37. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +11 -2
  38. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
  39. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
  40. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
  41. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/Navigation.svelte +11 -8
  42. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
  43. learningfoundry-0.49.0/sveltekit_template/src/lib/components/ResetCourseButton.svelte +46 -0
  44. learningfoundry-0.49.0/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +95 -0
  45. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
  46. learningfoundry-0.49.0/sveltekit_template/src/lib/components/TextBlock.test.ts +127 -0
  47. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
  48. learningfoundry-0.49.0/sveltekit_template/src/lib/components/lesson-view.helpers.ts +63 -0
  49. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
  50. learningfoundry-0.49.0/sveltekit_template/src/lib/components/module-list.test.ts +105 -0
  51. learningfoundry-0.49.0/sveltekit_template/src/lib/components/navigation.helpers.ts +43 -0
  52. learningfoundry-0.49.0/sveltekit_template/src/lib/components/navigation.test.ts +52 -0
  53. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/index.ts +1 -0
  54. learningfoundry-0.49.0/sveltekit_template/src/lib/db/progress.test.ts +44 -0
  55. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/progress.ts +20 -0
  56. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/curriculum.ts +10 -0
  57. learningfoundry-0.49.0/sveltekit_template/src/lib/utils/progress.test.ts +71 -0
  58. learningfoundry-0.49.0/sveltekit_template/src/lib/utils/progress.ts +26 -0
  59. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+layout.svelte +6 -0
  60. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
  61. learningfoundry-0.49.0/sveltekit_template/src/routes/layout.test.ts +102 -0
  62. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/.gitignore +0 -0
  63. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/LICENSE +0 -0
  64. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/README.md +0 -0
  65. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/docs/project-guide/README.md +0 -0
  66. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/__main__.py +0 -0
  67. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/asset_resolver.py +0 -0
  68. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/cli.py +0 -0
  69. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/config.py +0 -0
  70. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/exceptions.py +0 -0
  71. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/generator.py +0 -0
  72. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/__init__.py +0 -0
  73. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  74. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  75. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/protocols.py +0 -0
  76. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  77. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/logging_config.py +0 -0
  78. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/parser.py +0 -0
  79. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/pipeline.py +0 -0
  80. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/py.typed +0 -0
  81. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/resolver.py +0 -0
  82. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/schema_v1.py +0 -0
  83. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  84. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  85. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  86. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  87. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  88. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  89. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  90. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
  91. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  92. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  93. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  94. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  95. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  96. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  97. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  98. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  99. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
  100. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  101. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
  102. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  103. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  104. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  105. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  106. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  107. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  108. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  109. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  110. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  111. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  112. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  113. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.css +0 -0
  114. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.html +0 -0
  115. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  116. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  117. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  118. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  119. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  120. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  121. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  122. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  123. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
  124. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/types/index.ts +0 -0
  125. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/locking.ts +0 -0
  126. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  127. {learningfoundry-0.45.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  128. {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.45.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.45.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"
@@ -1,4 +1,4 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- __version__ = "0.45.0"
4
+ __version__ = "0.49.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 { currentPosition, navigateTo } from '$lib/stores/curriculum.js';
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
- navigateTo(moduleId, lessonId);
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
- if (pos && pos.moduleId !== lastAutoExpandedModuleId) {
49
- expandedModuleId = pos.moduleId;
50
- lastAutoExpandedModuleId = pos.moduleId;
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, navigateTo } from '$lib/stores/curriculum.js';
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
- if (disabled) return;
17
- if (next) {
18
- navigateTo(next.moduleId, next.lessonId);
19
- } else {
20
- void goto('/');
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
- if (prev) navigateTo(prev.moduleId, prev.lessonId);
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 { navigateTo } from '$lib/stores/curriculum.js';
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) navigateTo(mod.id, target.id);
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
+ });