learningfoundry 0.46.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.46.0 → learningfoundry-0.49.0}/CHANGELOG.md +18 -0
  2. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/PKG-INFO +1 -1
  3. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/pyproject.toml +1 -1
  4. {learningfoundry-0.46.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/reset.spec.ts +32 -0
  7. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +27 -0
  8. {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
  9. {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/Navigation.svelte +7 -2
  10. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +46 -0
  11. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +95 -0
  12. {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
  13. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +49 -0
  14. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
  15. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +40 -0
  16. {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/navigation.helpers.ts +13 -3
  17. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +11 -2
  18. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +1 -0
  19. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +44 -0
  20. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +20 -0
  21. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +71 -0
  22. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +26 -0
  23. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +6 -0
  24. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +11 -2
  25. {learningfoundry-0.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
  26. {learningfoundry-0.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/Navigation.svelte +7 -2
  27. learningfoundry-0.49.0/sveltekit_template/src/lib/components/ResetCourseButton.svelte +46 -0
  28. learningfoundry-0.49.0/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +95 -0
  29. {learningfoundry-0.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
  30. learningfoundry-0.49.0/sveltekit_template/src/lib/components/TextBlock.test.ts +127 -0
  31. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
  32. learningfoundry-0.49.0/sveltekit_template/src/lib/components/module-list.test.ts +105 -0
  33. {learningfoundry-0.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/navigation.helpers.ts +13 -3
  34. learningfoundry-0.49.0/sveltekit_template/src/lib/components/navigation.test.ts +52 -0
  35. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/index.ts +1 -0
  36. learningfoundry-0.49.0/sveltekit_template/src/lib/db/progress.test.ts +44 -0
  37. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/progress.ts +20 -0
  38. learningfoundry-0.49.0/sveltekit_template/src/lib/utils/progress.test.ts +71 -0
  39. learningfoundry-0.49.0/sveltekit_template/src/lib/utils/progress.ts +26 -0
  40. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+layout.svelte +6 -0
  41. learningfoundry-0.49.0/sveltekit_template/src/routes/layout.test.ts +102 -0
  42. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/.gitignore +0 -0
  43. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/LICENSE +0 -0
  44. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/README.md +0 -0
  45. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/docs/project-guide/README.md +0 -0
  46. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/__main__.py +0 -0
  47. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/asset_resolver.py +0 -0
  48. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/cli.py +0 -0
  49. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/config.py +0 -0
  50. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/exceptions.py +0 -0
  51. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/generator.py +0 -0
  52. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/__init__.py +0 -0
  53. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  54. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  55. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/protocols.py +0 -0
  56. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  57. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/logging_config.py +0 -0
  58. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/parser.py +0 -0
  59. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/pipeline.py +0 -0
  60. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/py.typed +0 -0
  61. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/resolver.py +0 -0
  62. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/schema_v1.py +0 -0
  63. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
  64. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
  65. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
  66. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
  67. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
  68. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  69. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  70. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  71. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  72. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  73. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  74. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  75. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -0
  76. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  77. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  78. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  79. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
  80. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  81. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  82. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  83. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  84. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  85. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  86. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  87. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  88. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  89. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  90. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  91. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
  92. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  93. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
  94. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  95. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  96. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  97. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  98. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  99. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  100. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  101. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  102. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  103. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  104. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  105. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  106. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.css +0 -0
  107. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.html +0 -0
  108. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  109. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  110. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  111. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  112. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  113. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  114. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  115. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  116. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  117. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  118. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  119. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  120. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  121. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  122. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
  123. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/types/index.ts +0 -0
  124. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/locking.ts +0 -0
  125. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  126. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  127. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+page.svelte +0 -0
  128. {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
@@ -7,6 +7,24 @@ 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
+
10
28
  ## [0.46.0] - 2026-05-01
11
29
 
12
30
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.46.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.46.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.46.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,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
+ });
@@ -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,7 @@
2
2
  <script lang="ts">
3
3
  import { goto } from '$app/navigation';
4
4
  import { ChevronLeft, ChevronRight } from 'lucide-svelte';
5
- import { nextLesson, previousLesson } from '$lib/stores/curriculum.js';
5
+ import { currentPosition, nextLesson, previousLesson } from '$lib/stores/curriculum.js';
6
6
  import { resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
7
7
 
8
8
  interface Props {
@@ -15,7 +15,12 @@
15
15
 
16
16
  function goNext() {
17
17
  const action = resolveGoNext(disabled, next);
18
- if (action.kind === 'goto') void goto(action.url);
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);
19
24
  }
20
25
 
21
26
  function goPrev() {
@@ -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
+ });
@@ -11,11 +11,17 @@
11
11
  let { content, ontextcomplete }: Props = $props();
12
12
 
13
13
  const html = $derived(renderMarkdown(content.markdown));
14
- let blockEl: HTMLDivElement | undefined = $state();
14
+ // Observe a zero-size sentinel placed at the *end* of the rendered
15
+ // markdown rather than the wrapper itself. Otherwise a tall block fires
16
+ // `textcomplete` simply because the top of the block is in view on
17
+ // initial render — the learner would never have to scroll to the lesson
18
+ // body. With the sentinel, completion requires the bottom of the block
19
+ // to be in view for 1 s.
20
+ let sentinelEl: HTMLDivElement | undefined = $state();
15
21
  let fired = false;
16
22
 
17
23
  onMount(() => {
18
- if (!blockEl || !ontextcomplete) return;
24
+ if (!sentinelEl || !ontextcomplete) return;
19
25
  let timer: ReturnType<typeof setTimeout> | null = null;
20
26
 
21
27
  const observer = new IntersectionObserver(
@@ -37,7 +43,7 @@
37
43
  { threshold: 0.1 }
38
44
  );
39
45
 
40
- observer.observe(blockEl);
46
+ observer.observe(sentinelEl);
41
47
 
42
48
  return () => {
43
49
  observer.disconnect();
@@ -46,6 +52,7 @@
46
52
  });
47
53
  </script>
48
54
 
49
- <div bind:this={blockEl} class="prose prose-slate max-w-none">
55
+ <div class="prose prose-slate max-w-none">
50
56
  {@html html}
57
+ <div bind:this={sentinelEl} aria-hidden="true" data-textblock-end></div>
51
58
  </div>
@@ -3,6 +3,12 @@
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { createViewportTracker } from '$lib/utils/viewport-completion.js';
5
5
 
6
+ // FR-P13: TextBlock completion observes a sentinel at the end of the
7
+ // rendered markdown — completion requires the *bottom* of the block to
8
+ // be in view for 1 s, not just any portion. The viewport tracker tested
9
+ // here is target-agnostic; the sentinel-vs-wrapper choice is made in
10
+ // `TextBlock.svelte` by which element it `observer.observe()`s.
11
+
6
12
  describe('TextBlock completion (via createViewportTracker, 1 s delay)', () => {
7
13
  beforeEach(() => {
8
14
  vi.useFakeTimers();
@@ -58,6 +64,49 @@ describe('TextBlock completion (via createViewportTracker, 1 s delay)', () => {
58
64
  tracker.destroy();
59
65
  });
60
66
 
67
+ it('tall block: sentinel never intersects → does not fire even after 5 s', () => {
68
+ const callback = vi.fn();
69
+ const tracker = createViewportTracker(callback, 1000);
70
+
71
+ // Sentinel at the end of a tall block is never in the viewport.
72
+ // The component never calls `handleIntersecting` for this case.
73
+ vi.advanceTimersByTime(5000);
74
+ expect(callback).not.toHaveBeenCalled();
75
+
76
+ tracker.destroy();
77
+ });
78
+
79
+ it('tall block: sentinel scrolled into view fires 1 s later', () => {
80
+ const callback = vi.fn();
81
+ const tracker = createViewportTracker(callback, 1000);
82
+
83
+ // Initial state: sentinel below the fold. Simulate a scroll that
84
+ // brings the sentinel into the viewport.
85
+ vi.advanceTimersByTime(2000);
86
+ expect(callback).not.toHaveBeenCalled();
87
+
88
+ tracker.handleIntersecting();
89
+ vi.advanceTimersByTime(999);
90
+ expect(callback).not.toHaveBeenCalled();
91
+ vi.advanceTimersByTime(1);
92
+ expect(callback).toHaveBeenCalledOnce();
93
+
94
+ tracker.destroy();
95
+ });
96
+
97
+ it('sentinel briefly visible (<1 s) then hidden → does not fire', () => {
98
+ const callback = vi.fn();
99
+ const tracker = createViewportTracker(callback, 1000);
100
+
101
+ tracker.handleIntersecting();
102
+ vi.advanceTimersByTime(700);
103
+ tracker.handleNotIntersecting();
104
+ vi.advanceTimersByTime(2000);
105
+
106
+ expect(callback).not.toHaveBeenCalled();
107
+ tracker.destroy();
108
+ });
109
+
61
110
  it('restarts timer when re-entering viewport after leaving', () => {
62
111
  const callback = vi.fn();
63
112
  const tracker = createViewportTracker(callback, 1000);
@@ -8,18 +8,33 @@
8
8
  */
9
9
 
10
10
  /**
11
- * Determine whether the sidebar should auto-expand a module because the
12
- * current navigation position has moved to a new module.
11
+ * Determine how the sidebar should react to a change in the current
12
+ * navigation position.
13
13
  *
14
- * Returns the new `expandedModuleId` and `lastAutoExpandedModuleId` values,
15
- * or `null` if no auto-expand should happen (i.e. the module was already
16
- * auto-expanded or there is no current position).
14
+ * Returns:
15
+ * - `null` no change required (position is null and nothing was
16
+ * previously auto-expanded; or the current module already matches
17
+ * the last auto-expanded module).
18
+ * - `{ expandedModuleId: null, lastAutoExpandedModuleId: null }` — the
19
+ * position was just cleared (FR-P14: Finish on the last lesson);
20
+ * collapse the previously expanded module and forget the auto-expand
21
+ * anchor so the next manual toggle starts from a clean slate.
22
+ * - `{ expandedModuleId, lastAutoExpandedModuleId }` — auto-expand the
23
+ * new module (and remember it as the auto-expand anchor so manual
24
+ * toggles aren't reverted; see Story I.f).
17
25
  */
18
26
  export function computeAutoExpand(
19
- currentModuleId: string | undefined,
27
+ currentModuleId: string | null | undefined,
20
28
  lastAutoExpandedModuleId: string | null
21
- ): { expandedModuleId: string; lastAutoExpandedModuleId: string } | null {
22
- if (!currentModuleId) return null;
29
+ ): { expandedModuleId: string | null; lastAutoExpandedModuleId: string | null } | null {
30
+ if (!currentModuleId) {
31
+ // Position cleared. Only emit a reset if we previously auto-expanded
32
+ // — otherwise we'd loop forever rewriting the same null values.
33
+ if (lastAutoExpandedModuleId !== null) {
34
+ return { expandedModuleId: null, lastAutoExpandedModuleId: null };
35
+ }
36
+ return null;
37
+ }
23
38
  if (currentModuleId === lastAutoExpandedModuleId) return null;
24
39
  return {
25
40
  expandedModuleId: currentModuleId,
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import {
5
+ computeAutoExpand,
5
6
  lessonStatusIcon,
6
7
  resolveLessonClick,
7
8
  resolveModuleHeaderClick
@@ -63,3 +64,42 @@ describe('lessonStatusIcon (optional rendering)', () => {
63
64
  expect(lessonStatusIcon('l1', 'not_started', new Set())).toBe('○');
64
65
  });
65
66
  });
67
+
68
+ describe('computeAutoExpand (FR-P14 sidebar reset on null position)', () => {
69
+ it('expanding into a new module: returns expand instruction', () => {
70
+ const result = computeAutoExpand('mod-01', null);
71
+ expect(result).toEqual({
72
+ expandedModuleId: 'mod-01',
73
+ lastAutoExpandedModuleId: 'mod-01'
74
+ });
75
+ });
76
+
77
+ it('staying in the same module: returns null (no-op)', () => {
78
+ expect(computeAutoExpand('mod-01', 'mod-01')).toBeNull();
79
+ });
80
+
81
+ it('null position with no prior auto-expand: null (no-op, prevents re-run loop)', () => {
82
+ expect(computeAutoExpand(null, null)).toBeNull();
83
+ });
84
+
85
+ it('null position after auto-expand: resets both expanded and last-auto', () => {
86
+ expect(computeAutoExpand(null, 'mod-01')).toEqual({
87
+ expandedModuleId: null,
88
+ lastAutoExpandedModuleId: null
89
+ });
90
+ });
91
+
92
+ it('after a Finish reset, subsequent auto-expand into a new module still works', () => {
93
+ // First: position cleared from mod-01 → both reset to null.
94
+ expect(computeAutoExpand(null, 'mod-01')).toEqual({
95
+ expandedModuleId: null,
96
+ lastAutoExpandedModuleId: null
97
+ });
98
+ // Then: navigating into mod-02 should auto-expand it (regression
99
+ // check that I.f's manual-toggle preservation is still intact).
100
+ expect(computeAutoExpand('mod-02', null)).toEqual({
101
+ expandedModuleId: 'mod-02',
102
+ lastAutoExpandedModuleId: 'mod-02'
103
+ });
104
+ });
105
+ });
@@ -9,16 +9,26 @@
9
9
  */
10
10
  import type { NavPosition } from '$lib/stores/curriculum.js';
11
11
 
12
- export type NavAction = { kind: 'noop' } | { kind: 'goto'; url: string };
12
+ export type NavAction =
13
+ | { kind: 'noop' }
14
+ | { kind: 'goto'; url: string; clearPosition?: boolean };
13
15
 
14
- /** Resolve the action for the Next/Finish button click. */
16
+ /**
17
+ * Resolve the action for the Next/Finish button click.
18
+ *
19
+ * On Finish (`next === null`) the action carries `clearPosition: true`
20
+ * so the caller wipes `currentPosition` *before* `goto('/')` runs —
21
+ * that lets the sidebar's auto-expand effect see the null transition
22
+ * and collapse the previously expanded module before the URL change
23
+ * settles (FR-P14).
24
+ */
15
25
  export function resolveGoNext(
16
26
  disabled: boolean,
17
27
  next: NavPosition | null
18
28
  ): NavAction {
19
29
  if (disabled) return { kind: 'noop' };
20
30
  if (next) return { kind: 'goto', url: `/${next.moduleId}/${next.lessonId}` };
21
- return { kind: 'goto', url: '/' };
31
+ return { kind: 'goto', url: '/', clearPosition: true };
22
32
  }
23
33
 
24
34
  /** Resolve the action for the Previous button click. */
@@ -9,9 +9,18 @@ describe('resolveGoNext (Next/Finish button)', () => {
9
9
  expect(action).toEqual({ kind: 'goto', url: '/mod-01/lesson-02' });
10
10
  });
11
11
 
12
- it('routes to / when next is null (Finish on last lesson)', () => {
12
+ it('routes to / when next is null (Finish on last lesson) and signals position-clear', () => {
13
13
  const action = resolveGoNext(false, null);
14
- expect(action).toEqual({ kind: 'goto', url: '/' });
14
+ expect(action).toEqual({ kind: 'goto', url: '/', clearPosition: true });
15
+ });
16
+
17
+ it('does not signal position-clear when there is a next lesson', () => {
18
+ const action = resolveGoNext(false, { moduleId: 'mod-01', lessonId: 'lesson-02' });
19
+ // `clearPosition` is omitted (or false-y) for in-curriculum navigation.
20
+ expect(action.kind).toBe('goto');
21
+ if (action.kind === 'goto') {
22
+ expect(action.clearPosition).toBeFalsy();
23
+ }
15
24
  });
16
25
 
17
26
  it('disabled state is a no-op even with a next lesson', () => {
@@ -7,6 +7,7 @@ export {
7
7
  getQuizScore,
8
8
  markLessonComplete,
9
9
  markLessonInProgress,
10
+ resetProgress,
10
11
  saveQuizScore,
11
12
  updateExerciseStatus
12
13
  } from './progress.js';
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ // `vi.mock` is hoisted to the top of the file, so any references inside
6
+ // the factory must come from `vi.hoisted` (which is also hoisted).
7
+ const { execMock, persistMock } = vi.hoisted(() => ({
8
+ execMock: vi.fn(),
9
+ persistMock: vi.fn().mockResolvedValue(undefined)
10
+ }));
11
+
12
+ vi.mock('./database.js', () => ({
13
+ getDb: vi.fn().mockResolvedValue({ exec: execMock, run: vi.fn() }),
14
+ persistDb: () => persistMock()
15
+ }));
16
+
17
+ import { resetProgress } from './progress.js';
18
+
19
+ describe('resetProgress', () => {
20
+ beforeEach(() => {
21
+ execMock.mockClear();
22
+ persistMock.mockClear();
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it('truncates lesson_progress, quiz_scores, and exercise_status in a single transaction', async () => {
30
+ await resetProgress();
31
+ expect(execMock).toHaveBeenCalledTimes(1);
32
+ const sql = String(execMock.mock.calls[0][0]);
33
+ expect(sql).toMatch(/BEGIN;/);
34
+ expect(sql).toMatch(/DELETE FROM lesson_progress;/);
35
+ expect(sql).toMatch(/DELETE FROM quiz_scores;/);
36
+ expect(sql).toMatch(/DELETE FROM exercise_status;/);
37
+ expect(sql).toMatch(/COMMIT;/);
38
+ });
39
+
40
+ it('persists after the truncate', async () => {
41
+ await resetProgress();
42
+ expect(persistMock).toHaveBeenCalledTimes(1);
43
+ });
44
+ });