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.
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/CHANGELOG.md +18 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/PKG-INFO +1 -1
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/pyproject.toml +1 -1
- {learningfoundry-0.46.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/reset.spec.ts +32 -0
- learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +27 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
- {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/Navigation.svelte +7 -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.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/TextBlock.svelte +11 -4
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +49 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +23 -8
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +40 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0/src/learningfoundry}/sveltekit_template/src/lib/components/navigation.helpers.ts +13 -3
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +11 -2
- {learningfoundry-0.46.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.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +20 -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.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +6 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +11 -2
- {learningfoundry-0.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ModuleList.svelte +8 -5
- {learningfoundry-0.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/Navigation.svelte +7 -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.46.0/src/learningfoundry → 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.46.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.46.0/src/learningfoundry → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/navigation.helpers.ts +13 -3
- learningfoundry-0.49.0/sveltekit_template/src/lib/components/navigation.test.ts +52 -0
- {learningfoundry-0.46.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.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/progress.ts +20 -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.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+layout.svelte +6 -0
- learningfoundry-0.49.0/sveltekit_template/src/routes/layout.test.ts +102 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/.gitignore +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/LICENSE +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/README.md +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/asset_resolver.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/cli.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/config.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/generator.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/pipeline.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/resolver.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/schema_v1.py +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.46.0 → learningfoundry-0.49.0}/sveltekit_template/src/routes/+page.svelte +0 -0
- {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.
|
|
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,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
|
-
|
|
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,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 === '
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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
|
|
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
|
|
12
|
-
*
|
|
11
|
+
* Determine how the sidebar should react to a change in the current
|
|
12
|
+
* navigation position.
|
|
13
13
|
*
|
|
14
|
-
* Returns
|
|
15
|
-
*
|
|
16
|
-
* auto-expanded or
|
|
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)
|
|
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 =
|
|
12
|
+
export type NavAction =
|
|
13
|
+
| { kind: 'noop' }
|
|
14
|
+
| { kind: 'goto'; url: string; clearPosition?: boolean };
|
|
13
15
|
|
|
14
|
-
/**
|
|
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', () => {
|
|
@@ -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
|
+
});
|