learningfoundry 0.62.0__tar.gz → 0.62.2__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.62.0 → learningfoundry-0.62.2}/CHANGELOG.md +20 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/PKG-INFO +1 -1
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/pyproject.toml +1 -1
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/__init__.py +1 -1
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/schema_v1.py +28 -12
- learningfoundry-0.62.2/src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte +39 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.test.ts +22 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +30 -3
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +63 -0
- learningfoundry-0.62.2/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +90 -0
- learningfoundry-0.62.2/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/page.test.ts +146 -0
- learningfoundry-0.62.0/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -45
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/.gitignore +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/LICENSE +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/README.md +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/asset_resolver.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/cli.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/config.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/generator.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/pipeline.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/resolver.py +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/README.md +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/global-teardown.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/package.json +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.helpers.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/test-results/.last-run.json +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.62.2] - 2026-05-02
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Dashboard "Start module →" CTA didn't reflect locked state** (Story I.aa.3). [ProgressDashboard.svelte](src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte) rendered the same active-blue button for every non-complete module regardless of whether `locking.sequential` had locked it. Story I.aa.2 caught the click at the lesson route and rendered a placeholder, but the dashboard was still inviting the click. Same anti-pattern as I.aa.2: locking enforcement was in one place (sidebar) and missed at every other entry point. Fix: dashboard now derives `lockedModules` from the same `lockedModuleIds` helper the sidebar uses, and renders a `Locked` indicator (Lucide Lock icon + `text-gray-400` + `aria-disabled="true"`) instead of the action button when a module is locked. The module title also picks up the Lock icon for visual cohesion with the sidebar idiom.
|
|
15
|
+
|
|
16
|
+
## [0.62.1] - 2026-05-02
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Locking config silently disabled when `sequential: true` was mis-placed in the YAML** (Story I.aa.2). Repro: a curriculum YAML with `sequential: true` written one indent level too high (directly under `curriculum:` instead of nested under `curriculum.locking:`) silently parsed with `locking.sequential = false`, so every module was freely expandable, every lesson freely openable, and no lock icon appeared in the sidebar. Pydantic's default `extra='ignore'` ate the unknown field without any warning. Compounded by [+page.svelte](src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte) having no locking guard at all — even with the schema fixed, typing or bookmarking a locked-lesson URL would have bypassed the sidebar's enforcement entirely.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Curriculum schema is now strict.** New `StrictModel` base class in [schema_v1.py](src/learningfoundry/schema_v1.py) sets `model_config = ConfigDict(extra='forbid')`. Every schema model (`CurriculumDef`, `Module`, `Lesson`, `LockingConfig`, `CurriculumV1`, `*Block` types, `AssessmentRef`) inherits from it. A misplaced or typo'd field anywhere in the YAML now produces a `ValidationError` with a JSON-pointer path to the offending field — e.g. `curriculum.sequential — Extra inputs are not permitted`. Breaking change for any curriculum YAML that contained benign extra fields; the project ships only the test fixtures and one user's curriculum, none of which use extras.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Lesson-route locking failsafe** (Story I.aa.2). [+page.svelte](src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte) now derives `isLocked` from the same `isModuleLocked` / `isLessonLocked` helpers the sidebar uses, and renders a new [LockedLessonPlaceholder.svelte](src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte) (lock icon + module/lesson titles + "Complete X to unlock this lesson" + Return-to-dashboard CTA) when the requested URL points at a locked lesson. The `navigateTo` side-effect is guarded by `!isLocked` so a locked-URL load doesn't write `currentPosition` and therefore doesn't highlight the gated module in the sidebar.
|
|
29
|
+
|
|
10
30
|
## [0.62.0] - 2026-05-02
|
|
11
31
|
|
|
12
32
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learningfoundry
|
|
3
|
-
Version: 0.62.
|
|
3
|
+
Version: 0.62.2
|
|
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.62.
|
|
7
|
+
version = "0.62.2"
|
|
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"
|
|
@@ -5,7 +5,23 @@
|
|
|
5
5
|
import re
|
|
6
6
|
from typing import Annotated, Any, Literal, Self
|
|
7
7
|
|
|
8
|
-
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StrictModel(BaseModel):
|
|
12
|
+
"""Base for every curriculum-schema model.
|
|
13
|
+
|
|
14
|
+
`extra='forbid'` makes Pydantic raise `ValidationError` on unknown
|
|
15
|
+
fields instead of silently dropping them — Story I.aa.2 root cause
|
|
16
|
+
was a `sequential: true` mis-placed at the curriculum top level
|
|
17
|
+
instead of nested under `locking:`. The unknown field was discarded
|
|
18
|
+
without a peep, the resolved curriculum.json shipped with
|
|
19
|
+
`locking.sequential = false`, and the entire module-locking feature
|
|
20
|
+
was silently disabled. Strict validation converts that class of typo
|
|
21
|
+
into a loud build-time error pointing at the offending field name.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
9
25
|
|
|
10
26
|
_ID_RE = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
|
|
11
27
|
YOUTUBE_URL_RE = re.compile(
|
|
@@ -26,17 +42,17 @@ def _validate_id(v: str, field_name: str = "id") -> str:
|
|
|
26
42
|
return v
|
|
27
43
|
|
|
28
44
|
|
|
29
|
-
class AssessmentRef(
|
|
45
|
+
class AssessmentRef(StrictModel):
|
|
30
46
|
source: str
|
|
31
47
|
ref: str
|
|
32
48
|
|
|
33
49
|
|
|
34
|
-
class TextBlock(
|
|
50
|
+
class TextBlock(StrictModel):
|
|
35
51
|
type: Literal["text"]
|
|
36
52
|
ref: str
|
|
37
53
|
|
|
38
54
|
|
|
39
|
-
class VideoBlock(
|
|
55
|
+
class VideoBlock(StrictModel):
|
|
40
56
|
"""Video embed. ``provider`` selects the player; ``extensions`` carries
|
|
41
57
|
player-specific options (chapters, transcript refs, etc.) without
|
|
42
58
|
forcing a one-size-fits-all schema across providers.
|
|
@@ -59,20 +75,20 @@ class VideoBlock(BaseModel):
|
|
|
59
75
|
return self
|
|
60
76
|
|
|
61
77
|
|
|
62
|
-
class QuizBlock(
|
|
78
|
+
class QuizBlock(StrictModel):
|
|
63
79
|
type: Literal["quiz"]
|
|
64
80
|
source: str
|
|
65
81
|
ref: str
|
|
66
82
|
pass_threshold: float = Field(0.0, ge=0.0, le=1.0)
|
|
67
83
|
|
|
68
84
|
|
|
69
|
-
class ExerciseBlock(
|
|
85
|
+
class ExerciseBlock(StrictModel):
|
|
70
86
|
type: Literal["exercise"]
|
|
71
87
|
source: str
|
|
72
88
|
ref: str
|
|
73
89
|
|
|
74
90
|
|
|
75
|
-
class VisualizationBlock(
|
|
91
|
+
class VisualizationBlock(StrictModel):
|
|
76
92
|
type: Literal["visualization"]
|
|
77
93
|
source: str
|
|
78
94
|
ref: str
|
|
@@ -84,7 +100,7 @@ ContentBlock = Annotated[
|
|
|
84
100
|
]
|
|
85
101
|
|
|
86
102
|
|
|
87
|
-
class Lesson(
|
|
103
|
+
class Lesson(StrictModel):
|
|
88
104
|
id: str
|
|
89
105
|
title: str
|
|
90
106
|
unlock_module_on_complete: bool = False
|
|
@@ -96,7 +112,7 @@ class Lesson(BaseModel):
|
|
|
96
112
|
return _validate_id(v, "lesson id")
|
|
97
113
|
|
|
98
114
|
|
|
99
|
-
class Module(
|
|
115
|
+
class Module(StrictModel):
|
|
100
116
|
id: str
|
|
101
117
|
title: str
|
|
102
118
|
description: str = ""
|
|
@@ -117,14 +133,14 @@ class Module(BaseModel):
|
|
|
117
133
|
return self
|
|
118
134
|
|
|
119
135
|
|
|
120
|
-
class LockingConfig(
|
|
136
|
+
class LockingConfig(StrictModel):
|
|
121
137
|
"""Curriculum-level content locking configuration."""
|
|
122
138
|
|
|
123
139
|
sequential: bool = False
|
|
124
140
|
lesson_sequential: bool = False
|
|
125
141
|
|
|
126
142
|
|
|
127
|
-
class CurriculumDef(
|
|
143
|
+
class CurriculumDef(StrictModel):
|
|
128
144
|
title: str
|
|
129
145
|
description: str = ""
|
|
130
146
|
locking: LockingConfig = Field(default_factory=LockingConfig)
|
|
@@ -158,6 +174,6 @@ class CurriculumDef(BaseModel):
|
|
|
158
174
|
return self
|
|
159
175
|
|
|
160
176
|
|
|
161
|
-
class CurriculumV1(
|
|
177
|
+
class CurriculumV1(StrictModel):
|
|
162
178
|
version: str
|
|
163
179
|
curriculum: CurriculumDef
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import Lock from 'lucide-svelte/icons/lock';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
moduleTitle: string;
|
|
7
|
+
lessonTitle: string;
|
|
8
|
+
blockedBy: string | null;
|
|
9
|
+
}
|
|
10
|
+
let { moduleTitle, lessonTitle, blockedBy }: Props = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<section
|
|
14
|
+
class="mx-auto flex max-w-xl flex-col items-center gap-4 px-6 py-16 text-center"
|
|
15
|
+
>
|
|
16
|
+
<div class="rounded-full bg-gray-100 p-4 text-gray-500">
|
|
17
|
+
<Lock size={32} aria-hidden="true" />
|
|
18
|
+
</div>
|
|
19
|
+
<h1 class="text-xl font-semibold text-gray-800">Lesson is locked</h1>
|
|
20
|
+
<p class="text-sm leading-relaxed text-gray-600">
|
|
21
|
+
<span class="font-medium">{moduleTitle}</span> —
|
|
22
|
+
<span>{lessonTitle}</span>
|
|
23
|
+
</p>
|
|
24
|
+
{#if blockedBy}
|
|
25
|
+
<p class="text-sm text-gray-600">
|
|
26
|
+
Complete <span class="font-medium">{blockedBy}</span> to unlock this lesson.
|
|
27
|
+
</p>
|
|
28
|
+
{:else}
|
|
29
|
+
<p class="text-sm text-gray-600">
|
|
30
|
+
This lesson is gated by the curriculum's locking rules.
|
|
31
|
+
</p>
|
|
32
|
+
{/if}
|
|
33
|
+
<a
|
|
34
|
+
href="/"
|
|
35
|
+
class="mt-2 inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
36
|
+
>
|
|
37
|
+
Return to dashboard
|
|
38
|
+
</a>
|
|
39
|
+
</section>
|
|
@@ -135,6 +135,28 @@ describe('ModuleList mount — locked vs unlocked modules', () => {
|
|
|
135
135
|
expect(unlockedItem.querySelector('ul')).not.toBeNull();
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
// Story I.aa.2 — visual regression guard. The lock icon + aria-disabled
|
|
139
|
+
// is covered above; this pins the rest of the locked-module styling
|
|
140
|
+
// (cursor-not-allowed on the button, gray-400 text) so a future
|
|
141
|
+
// refactor of the Tailwind classes can't silently regress the visual
|
|
142
|
+
// indicator that tells learners a module is gated.
|
|
143
|
+
it('locked module button carries cursor-not-allowed and gray-400 styling', () => {
|
|
144
|
+
const m1 = makeModule('mod-01', 'First');
|
|
145
|
+
const m2 = makeModule('mod-02', 'Locked');
|
|
146
|
+
const progress = {
|
|
147
|
+
'mod-01': emptyProgress(m1),
|
|
148
|
+
'mod-02': emptyProgress(m2)
|
|
149
|
+
};
|
|
150
|
+
const { container } = render(ModuleList, {
|
|
151
|
+
props: { modules: [m1, m2], progress, lockedModules: new Set(['mod-02']) }
|
|
152
|
+
});
|
|
153
|
+
const lockedBtn = (
|
|
154
|
+
container.querySelectorAll('nav > ul > li')[1] as HTMLElement
|
|
155
|
+
).querySelector('button') as HTMLButtonElement;
|
|
156
|
+
expect(lockedBtn.className).toContain('cursor-not-allowed');
|
|
157
|
+
expect(lockedBtn.className).toContain('text-gray-400');
|
|
158
|
+
});
|
|
159
|
+
|
|
138
160
|
// Story I.aa.1 — orthogonal coverage to Story I.y. The previous fix
|
|
139
161
|
// handled "active lesson present, click course title": the position
|
|
140
162
|
// transitioned from non-null → null and the auto-expand $effect's
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import { goto } from '$app/navigation';
|
|
4
4
|
import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
|
|
5
|
-
import { getOptionalLessons } from '$lib/utils/locking.js';
|
|
5
|
+
import { getOptionalLessons, lockedModuleIds } from '$lib/utils/locking.js';
|
|
6
6
|
import { moduleStatus } from './progress-dashboard.helpers.js';
|
|
7
7
|
import ProgressBar from './ProgressBar.svelte';
|
|
8
|
+
import Lock from 'lucide-svelte/icons/lock';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
modules: Module[];
|
|
@@ -57,6 +58,14 @@
|
|
|
57
58
|
const overallPct = $derived(
|
|
58
59
|
totalLessons === 0 ? 0 : Math.round((totalComplete / totalLessons) * 100)
|
|
59
60
|
);
|
|
61
|
+
|
|
62
|
+
// Story I.aa.3 — third entry-point closure for the locking model.
|
|
63
|
+
// The sidebar (Story I.i / I.j) and the lesson route (Story I.aa.2)
|
|
64
|
+
// already enforce locking. The dashboard CTA was the remaining
|
|
65
|
+
// invitation to a click that should not be available.
|
|
66
|
+
const lockedModules = $derived<Set<string>>(
|
|
67
|
+
curriculum ? lockedModuleIds(curriculum, progress) : new Set<string>()
|
|
68
|
+
);
|
|
60
69
|
</script>
|
|
61
70
|
|
|
62
71
|
<div class="space-y-6">
|
|
@@ -70,9 +79,19 @@
|
|
|
70
79
|
<div class="space-y-4">
|
|
71
80
|
{#each modules as mod (mod.id)}
|
|
72
81
|
{@const stats = moduleStats(mod)}
|
|
82
|
+
{@const locked = lockedModules.has(mod.id)}
|
|
73
83
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
|
74
84
|
<div class="mb-2 flex items-center justify-between">
|
|
75
|
-
<h3
|
|
85
|
+
<h3
|
|
86
|
+
class="flex items-center gap-2 text-sm font-medium {locked
|
|
87
|
+
? 'text-gray-400'
|
|
88
|
+
: 'text-gray-800'}"
|
|
89
|
+
>
|
|
90
|
+
{#if locked}
|
|
91
|
+
<Lock size={14} aria-hidden="true" />
|
|
92
|
+
{/if}
|
|
93
|
+
{mod.title}
|
|
94
|
+
</h3>
|
|
76
95
|
<span class="text-xs text-gray-500">{stats.done}/{stats.total} lessons</span>
|
|
77
96
|
</div>
|
|
78
97
|
{#if mod.description}
|
|
@@ -93,7 +112,15 @@
|
|
|
93
112
|
</p>
|
|
94
113
|
{/if}
|
|
95
114
|
|
|
96
|
-
{#if
|
|
115
|
+
{#if locked}
|
|
116
|
+
<p
|
|
117
|
+
class="mt-3 inline-flex items-center gap-1 text-xs font-medium text-gray-400"
|
|
118
|
+
aria-disabled="true"
|
|
119
|
+
>
|
|
120
|
+
<Lock size={12} aria-hidden="true" />
|
|
121
|
+
Locked
|
|
122
|
+
</p>
|
|
123
|
+
{:else if stats.status !== 'complete'}
|
|
97
124
|
<button
|
|
98
125
|
onclick={() => resumeFirst(mod)}
|
|
99
126
|
class="mt-3 text-xs font-medium text-blue-600 hover:underline"
|
|
@@ -313,3 +313,66 @@ describe('ProgressDashboard mount — per-module action vs ✓ Complete', () =>
|
|
|
313
313
|
expect(actionBtn!.textContent).toContain('Start module →');
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
|
+
|
|
317
|
+
// Story I.aa.3 — locked-module CTA. Story I.aa.2 added the lesson-route
|
|
318
|
+
// failsafe so a learner who *deep-links* a locked URL gets the placeholder.
|
|
319
|
+
// This story closes the third entry point: the dashboard's "Start module
|
|
320
|
+
// →" button must reflect locked state, not invite the click in the first
|
|
321
|
+
// place. Same fix shape as I.aa.2 — lock state is derived from the same
|
|
322
|
+
// `lockedModuleIds` helper the sidebar uses; the CTA renders a disabled
|
|
323
|
+
// "Locked" indicator instead of an action button.
|
|
324
|
+
describe('ProgressDashboard mount — locked module CTA (Story I.aa.3)', () => {
|
|
325
|
+
let ProgressDashboard: typeof import('./ProgressDashboard.svelte').default;
|
|
326
|
+
|
|
327
|
+
beforeEach(async () => {
|
|
328
|
+
ProgressDashboard = (await import('./ProgressDashboard.svelte')).default;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
vi.clearAllMocks();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('locked sequential module renders a "Locked" indicator and no Start-module button; unlocked sibling still renders the button', async () => {
|
|
336
|
+
const m1 = makeModule('mod-01', 2);
|
|
337
|
+
const m2 = makeModule('mod-02', 2);
|
|
338
|
+
const curriculum = {
|
|
339
|
+
version: '1.0.0',
|
|
340
|
+
title: 'T',
|
|
341
|
+
description: '',
|
|
342
|
+
locking: { sequential: true, lesson_sequential: false },
|
|
343
|
+
modules: [
|
|
344
|
+
{ ...m1, locked: null, pre_assessment: null, post_assessment: null },
|
|
345
|
+
{ ...m2, locked: null, pre_assessment: null, post_assessment: null }
|
|
346
|
+
]
|
|
347
|
+
};
|
|
348
|
+
const progress = {
|
|
349
|
+
'mod-01': makeProgress(m1, 0), // not complete → m2 stays locked
|
|
350
|
+
'mod-02': makeProgress(m2, 0)
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const { render } = await import('@testing-library/svelte');
|
|
354
|
+
const { container } = render(ProgressDashboard, {
|
|
355
|
+
// `curriculum` is required for lock derivation.
|
|
356
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
357
|
+
props: { modules: [m1, m2], progress, curriculum: curriculum as any }
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const cards = container.querySelectorAll('div.rounded-lg.border');
|
|
361
|
+
expect(cards.length).toBe(2);
|
|
362
|
+
const unlockedCard = cards[0] as HTMLElement;
|
|
363
|
+
const lockedCard = cards[1] as HTMLElement;
|
|
364
|
+
|
|
365
|
+
// Unlocked module 1 still has the action button.
|
|
366
|
+
expect(unlockedCard.querySelector('button')?.textContent ?? '').toContain(
|
|
367
|
+
'Start module →'
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// Locked module 2: no action button, "Locked" indicator visible,
|
|
371
|
+
// rendered with the same gray-400 styling as the sidebar's locked items.
|
|
372
|
+
expect(lockedCard.querySelector('button')).toBeNull();
|
|
373
|
+
expect(lockedCard.textContent ?? '').toMatch(/locked/i);
|
|
374
|
+
// Lucide Lock SVG carries the `lucide-lock` class — same idiom as
|
|
375
|
+
// the sidebar's locked-module rendering for visual consistency.
|
|
376
|
+
expect(lockedCard.querySelector('svg.lucide-lock')).not.toBeNull();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { page } from '$app/state';
|
|
4
|
+
import { curriculum, navigateTo } from '$lib/stores/curriculum.js';
|
|
5
|
+
import { progressStore } from '$lib/stores/progress.js';
|
|
6
|
+
import type { Lesson, Module } from '$lib/types/index.js';
|
|
7
|
+
import { isLessonLocked, isModuleLocked } from '$lib/utils/locking.js';
|
|
8
|
+
import LessonView from '$lib/components/LessonView.svelte';
|
|
9
|
+
import LockedLessonPlaceholder from '$lib/components/LockedLessonPlaceholder.svelte';
|
|
10
|
+
import { onMount } from 'svelte';
|
|
11
|
+
|
|
12
|
+
const moduleId = $derived(page.params.module);
|
|
13
|
+
const lessonId = $derived(page.params.lesson);
|
|
14
|
+
|
|
15
|
+
const currentModule = $derived<Module | null>(
|
|
16
|
+
$curriculum?.modules.find((m) => m.id === moduleId) ?? null
|
|
17
|
+
);
|
|
18
|
+
const currentLesson = $derived<Lesson | null>(
|
|
19
|
+
currentModule?.lessons.find((l) => l.id === lessonId) ?? null
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Story I.aa.2 — locking failsafe. The sidebar already filters
|
|
23
|
+
// locked-module clicks (Story I.i / I.j), but a learner who types or
|
|
24
|
+
// bookmarks a locked-lesson URL would otherwise bypass the model
|
|
25
|
+
// entirely. Compute lock state here from the same helpers the
|
|
26
|
+
// sidebar uses, render a placeholder if locked, and skip the
|
|
27
|
+
// `navigateTo` side-effect so the sidebar doesn't highlight a module
|
|
28
|
+
// the learner can't actually access.
|
|
29
|
+
const moduleIndex = $derived<number>(
|
|
30
|
+
$curriculum?.modules.findIndex((m) => m.id === moduleId) ?? -1
|
|
31
|
+
);
|
|
32
|
+
const lessonIndex = $derived<number>(
|
|
33
|
+
currentModule?.lessons.findIndex((l) => l.id === lessonId) ?? -1
|
|
34
|
+
);
|
|
35
|
+
const isLocked = $derived<boolean>(
|
|
36
|
+
!!$curriculum &&
|
|
37
|
+
!!currentModule &&
|
|
38
|
+
!!currentLesson &&
|
|
39
|
+
(isModuleLocked(moduleIndex, $curriculum, $progressStore) ||
|
|
40
|
+
isLessonLocked(currentModule.id, lessonIndex, $curriculum, $progressStore))
|
|
41
|
+
);
|
|
42
|
+
const blockedByTitle = $derived<string | null>(
|
|
43
|
+
(() => {
|
|
44
|
+
if (!isLocked || !$curriculum || !currentModule) return null;
|
|
45
|
+
// `lesson_sequential` blocks on the previous lesson within the same module.
|
|
46
|
+
if (lessonIndex > 0 && $curriculum.locking?.lesson_sequential) {
|
|
47
|
+
return currentModule.lessons[lessonIndex - 1]?.title ?? null;
|
|
48
|
+
}
|
|
49
|
+
// Otherwise the block is module-level: name the previous module.
|
|
50
|
+
if (moduleIndex > 0) return $curriculum.modules[moduleIndex - 1]?.title ?? null;
|
|
51
|
+
return null;
|
|
52
|
+
})()
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Sync URL params into the curriculum store position — but only when
|
|
56
|
+
// the lesson is *accessible*. Navigating to a locked URL must not
|
|
57
|
+
// highlight the locked module in the sidebar; that would be visually
|
|
58
|
+
// inconsistent with "you can't go here."
|
|
59
|
+
onMount(() => {
|
|
60
|
+
if (moduleId && lessonId && !isLocked) navigateTo(moduleId, lessonId);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
$effect(() => {
|
|
64
|
+
if (moduleId && lessonId && !isLocked) navigateTo(moduleId, lessonId);
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<svelte:head>
|
|
69
|
+
<title>{currentLesson?.title ?? 'Lesson'} — {$curriculum?.title ?? 'LearningFoundry'}</title>
|
|
70
|
+
</svelte:head>
|
|
71
|
+
|
|
72
|
+
{#if currentLesson && currentModule && isLocked}
|
|
73
|
+
<LockedLessonPlaceholder
|
|
74
|
+
moduleTitle={currentModule.title}
|
|
75
|
+
lessonTitle={currentLesson.title}
|
|
76
|
+
blockedBy={blockedByTitle}
|
|
77
|
+
/>
|
|
78
|
+
{:else if currentLesson && currentModule}
|
|
79
|
+
{#key `${currentModule.id}/${currentLesson.id}`}
|
|
80
|
+
<LessonView lesson={currentLesson} moduleId={currentModule.id} />
|
|
81
|
+
{/key}
|
|
82
|
+
{:else if $curriculum}
|
|
83
|
+
<div class="flex h-full items-center justify-center">
|
|
84
|
+
<p class="text-gray-400">Lesson not found.</p>
|
|
85
|
+
</div>
|
|
86
|
+
{:else}
|
|
87
|
+
<div class="flex h-full items-center justify-center">
|
|
88
|
+
<p class="text-gray-400">Loading…</p>
|
|
89
|
+
</div>
|
|
90
|
+
{/if}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// Story I.aa.2 — locking failsafe at the lesson route. The sidebar
|
|
5
|
+
// already filters locked modules from clicks (Story I.i / I.j), but a
|
|
6
|
+
// learner who types or bookmarks a locked-lesson URL would otherwise
|
|
7
|
+
// land on the full LessonView, bypassing the locking model entirely.
|
|
8
|
+
// The route must render a "locked" placeholder instead.
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { render } from '@testing-library/svelte';
|
|
11
|
+
import { writable } from 'svelte/store';
|
|
12
|
+
import type { Curriculum, ModuleProgress } from '$lib/types/index.js';
|
|
13
|
+
|
|
14
|
+
const { pageState } = vi.hoisted(() => ({
|
|
15
|
+
pageState: { params: { module: 'mod-02', lesson: 'lesson-01' } }
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('$app/state', () => ({ page: pageState }));
|
|
19
|
+
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|
20
|
+
|
|
21
|
+
// Real writables so the page reactively reads the curriculum + progress.
|
|
22
|
+
const curriculumStore = writable<Curriculum | null>(null);
|
|
23
|
+
const progressStore = writable<Record<string, ModuleProgress>>({});
|
|
24
|
+
|
|
25
|
+
vi.mock('$lib/stores/curriculum.js', async () => {
|
|
26
|
+
const actual = await vi.importActual<
|
|
27
|
+
typeof import('$lib/stores/curriculum.js')
|
|
28
|
+
>('$lib/stores/curriculum.js');
|
|
29
|
+
return {
|
|
30
|
+
...actual,
|
|
31
|
+
curriculum: curriculumStore,
|
|
32
|
+
navigateTo: vi.fn()
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
vi.mock('$lib/stores/progress.js', async () => {
|
|
37
|
+
const actual = await vi.importActual<
|
|
38
|
+
typeof import('$lib/stores/progress.js')
|
|
39
|
+
>('$lib/stores/progress.js');
|
|
40
|
+
return {
|
|
41
|
+
...actual,
|
|
42
|
+
progressStore,
|
|
43
|
+
invalidateProgress: vi.fn()
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// LessonView's onMount writes through the SQLite repo on lesson open,
|
|
48
|
+
// which would touch `localStorage` / IDB / the wasm fetch — none wired
|
|
49
|
+
// up under this test. Stub the repo to no-op so we can assert purely on
|
|
50
|
+
// the route's render decision.
|
|
51
|
+
vi.mock('$lib/db/index.js', () => ({
|
|
52
|
+
progressRepo: {
|
|
53
|
+
markLessonOpened: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
markLessonInProgress: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
markLessonComplete: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
recordQuizScore: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
recordExerciseStatus: vi.fn().mockResolvedValue(undefined),
|
|
58
|
+
getLessonProgress: vi.fn().mockResolvedValue(null),
|
|
59
|
+
listAllProgress: vi.fn().mockResolvedValue([])
|
|
60
|
+
},
|
|
61
|
+
database: { getDb: vi.fn(), persist: vi.fn() }
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
function makeCurriculum(): Curriculum {
|
|
65
|
+
return {
|
|
66
|
+
version: '1.0.0',
|
|
67
|
+
title: 'T',
|
|
68
|
+
description: '',
|
|
69
|
+
locking: { sequential: true, lesson_sequential: false },
|
|
70
|
+
modules: [
|
|
71
|
+
{
|
|
72
|
+
id: 'mod-01',
|
|
73
|
+
title: 'M1',
|
|
74
|
+
description: '',
|
|
75
|
+
locked: null,
|
|
76
|
+
pre_assessment: null,
|
|
77
|
+
post_assessment: null,
|
|
78
|
+
lessons: [
|
|
79
|
+
{
|
|
80
|
+
id: 'lesson-01',
|
|
81
|
+
title: 'L1',
|
|
82
|
+
unlock_module_on_complete: false,
|
|
83
|
+
content_blocks: []
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'mod-02',
|
|
89
|
+
title: 'M2',
|
|
90
|
+
description: '',
|
|
91
|
+
locked: null,
|
|
92
|
+
pre_assessment: null,
|
|
93
|
+
post_assessment: null,
|
|
94
|
+
lessons: [
|
|
95
|
+
{
|
|
96
|
+
id: 'lesson-01',
|
|
97
|
+
title: 'L1',
|
|
98
|
+
unlock_module_on_complete: false,
|
|
99
|
+
content_blocks: []
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
} as Curriculum;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('lesson route — locking failsafe (Story I.aa.2)', () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
curriculumStore.set(null);
|
|
110
|
+
progressStore.set({});
|
|
111
|
+
pageState.params = { module: 'mod-02', lesson: 'lesson-01' };
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
vi.clearAllMocks();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders a Locked placeholder (not the LessonView) when navigating directly to a lesson in a sequentially-locked module', async () => {
|
|
119
|
+
curriculumStore.set(makeCurriculum());
|
|
120
|
+
progressStore.set({}); // Module 1 not complete → Module 2 locked.
|
|
121
|
+
|
|
122
|
+
const Page = (await import('./+page.svelte')).default;
|
|
123
|
+
const { container } = render(Page);
|
|
124
|
+
|
|
125
|
+
// LessonView mounts an <article> root; locked placeholder must not.
|
|
126
|
+
expect(container.querySelector('article')).toBeNull();
|
|
127
|
+
// Placeholder should announce the locked state.
|
|
128
|
+
expect(container.textContent).toMatch(/locked/i);
|
|
129
|
+
// And offer a return path to the dashboard.
|
|
130
|
+
const link = container.querySelector('a[href="/"]');
|
|
131
|
+
expect(link).not.toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('renders the LessonView when the requested lesson is in an unlocked module', async () => {
|
|
135
|
+
curriculumStore.set(makeCurriculum());
|
|
136
|
+
progressStore.set({});
|
|
137
|
+
pageState.params = { module: 'mod-01', lesson: 'lesson-01' };
|
|
138
|
+
|
|
139
|
+
const Page = (await import('./+page.svelte')).default;
|
|
140
|
+
const { container } = render(Page);
|
|
141
|
+
|
|
142
|
+
// Module 1 is the first sequential module → not locked.
|
|
143
|
+
expect(container.querySelector('article')).not.toBeNull();
|
|
144
|
+
expect(container.textContent ?? '').not.toMatch(/this lesson is locked/i);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
-
<script lang="ts">
|
|
3
|
-
import { page } from '$app/state';
|
|
4
|
-
import { curriculum, navigateTo } from '$lib/stores/curriculum.js';
|
|
5
|
-
import type { Lesson, Module } from '$lib/types/index.js';
|
|
6
|
-
import LessonView from '$lib/components/LessonView.svelte';
|
|
7
|
-
import { onMount } from 'svelte';
|
|
8
|
-
|
|
9
|
-
const moduleId = $derived(page.params.module);
|
|
10
|
-
const lessonId = $derived(page.params.lesson);
|
|
11
|
-
|
|
12
|
-
const currentModule = $derived<Module | null>(
|
|
13
|
-
$curriculum?.modules.find((m) => m.id === moduleId) ?? null
|
|
14
|
-
);
|
|
15
|
-
const currentLesson = $derived<Lesson | null>(
|
|
16
|
-
currentModule?.lessons.find((l) => l.id === lessonId) ?? null
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
// Sync URL params into the curriculum store position
|
|
20
|
-
onMount(() => {
|
|
21
|
-
if (moduleId && lessonId) navigateTo(moduleId, lessonId);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
$effect(() => {
|
|
25
|
-
if (moduleId && lessonId) navigateTo(moduleId, lessonId);
|
|
26
|
-
});
|
|
27
|
-
</script>
|
|
28
|
-
|
|
29
|
-
<svelte:head>
|
|
30
|
-
<title>{currentLesson?.title ?? 'Lesson'} — {$curriculum?.title ?? 'LearningFoundry'}</title>
|
|
31
|
-
</svelte:head>
|
|
32
|
-
|
|
33
|
-
{#if currentLesson && currentModule}
|
|
34
|
-
{#key `${currentModule.id}/${currentLesson.id}`}
|
|
35
|
-
<LessonView lesson={currentLesson} moduleId={currentModule.id} />
|
|
36
|
-
{/key}
|
|
37
|
-
{:else if $curriculum}
|
|
38
|
-
<div class="flex h-full items-center justify-center">
|
|
39
|
-
<p class="text-gray-400">Lesson not found.</p>
|
|
40
|
-
</div>
|
|
41
|
-
{:else}
|
|
42
|
-
<div class="flex h-full items-center justify-center">
|
|
43
|
-
<p class="text-gray-400">Loading…</p>
|
|
44
|
-
</div>
|
|
45
|
-
{/if}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/__init__.py
RENAMED
|
File without changes
|
{learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/d3foundry_stub.py
RENAMED
|
File without changes
|
{learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/nbfoundry_stub.py
RENAMED
|
File without changes
|
{learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/protocols.py
RENAMED
|
File without changes
|
{learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/quizazz.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/app.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|