learningfoundry 0.45.0__tar.gz → 0.46.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/CHANGELOG.md +17 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/PKG-INFO +1 -1
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/pyproject.toml +1 -1
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/__init__.py +1 -1
- learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +30 -0
- learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +24 -0
- learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +27 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
- learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/playwright.config.ts +27 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
- {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +50 -1
- {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/Navigation.svelte +6 -8
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +15 -0
- learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +33 -0
- learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +43 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +11 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/Navigation.svelte +6 -8
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
- learningfoundry-0.46.0/sveltekit_template/src/lib/components/lesson-view.helpers.ts +63 -0
- learningfoundry-0.46.0/sveltekit_template/src/lib/components/navigation.helpers.ts +33 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/curriculum.ts +10 -0
- {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/.gitignore +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/LICENSE +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/README.md +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/asset_resolver.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/cli.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/config.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/generator.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/pipeline.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/resolver.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/schema_v1.py +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/locking.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+page.svelte +0 -0
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.46.0] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Lesson navigation routing.** Sidebar lesson clicks, the Next/Finish button, and dashboard "Start module / Continue" buttons now call `goto()` from `$app/navigation` directly instead of going through the curriculum store helper. The previous flow updated `currentPosition` (and therefore the sidebar highlight) but left the URL untouched, so the lesson route was never re-mounted: `markLessonInProgress` ran only on the first lesson reached by direct URL, the sidebar checkmarks never updated, and the curriculum/module progress bars stayed at zero across the session. Components now route via `Navigation.svelte`, `LessonList.svelte`, `ProgressDashboard.svelte` → `goto('/${moduleId}/${lessonId}')`.
|
|
15
|
+
- **Sticky LessonView state across navigations.** The dynamic lesson route now wraps `<LessonView>` in `{#key \`${moduleId}/${lessonId}\`}` so the subtree tears down and re-mounts whenever either route param changes — guaranteeing fresh `allBlocksComplete` / `completedBlocks` state and a re-run of the on-mount progress check (so revisiting a previously-completed lesson activates Next/Finish immediately).
|
|
16
|
+
- **Stale video iframe across consecutive video lessons.** `LessonView`'s `{#each lesson.content_blocks}` now uses a stable identity key derived from `block.ref` or `block.content.url` (falling back to `${type}-${index}`); previously, two consecutive lessons each with a `video` block reused the same `<VideoBlock>` instance and its iframe player, leaving the previous lesson's video on screen. `VideoBlock.svelte` additionally tracks `content.url` via `$effect` and tears down / recreates its YouTube player whenever the URL changes, as belt-and-suspenders coverage.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Playwright e2e harness.** New `e2e/` directory with three regression specs (`navigation.spec.ts`, `progress.spec.ts`, `video.spec.ts`) covering the FR-P9/FR-P10 lifecycle invariants that vitest cannot exercise (because vitest mocks `$app/navigation`). New `pnpm e2e` script and `playwright.config.ts` driving `pnpm preview` against the built static site. The smoke test runs `pnpm e2e` after `pnpm build` and skips gracefully if Playwright browsers aren't installed locally; CI installs them via `pnpm exec playwright install chromium`.
|
|
21
|
+
- **Vitest navigation regression coverage.** New `navigation.helpers.ts` (`resolveGoNext`, `resolveGoPrev`, `lessonHref`) and `navigation.test.ts` lock down the routing decisions made by Next/Finish, Previous, and lesson rows. New `contentBlockKey` helper (and tests) verifies the FR-P10 stable-identity convention used by `{#each}`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `navigateTo` in `$lib/stores/curriculum.ts` is now documented as **internal route-sync only — UI code must use `goto` directly**. The function continues to set `currentPosition` for use by the dynamic lesson route's URL→store `$effect`; UI code (sidebar, dashboard, Next/Finish) routes via `goto()` so SvelteKit's full navigation lifecycle (page params, scroll restoration, `{#key}` re-mount) fires predictably.
|
|
26
|
+
|
|
10
27
|
## [0.45.0] - 2026-04-30
|
|
11
28
|
|
|
12
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learningfoundry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.46.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.46.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,30 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P9 regression coverage: in-app navigation must update the URL.
|
|
5
|
+
// Before v0.46.0 the sidebar / Next button updated `currentPosition` but
|
|
6
|
+
// not the route, so `LessonView` was never re-mounted on lesson change.
|
|
7
|
+
import { expect, test } from '@playwright/test';
|
|
8
|
+
|
|
9
|
+
test.describe('lesson navigation routing', () => {
|
|
10
|
+
test('sidebar lesson click updates URL', async ({ page }) => {
|
|
11
|
+
await page.goto('/');
|
|
12
|
+
// Open the first module by clicking its header in the sidebar.
|
|
13
|
+
const firstModuleHeader = page.locator('aside nav button').first();
|
|
14
|
+
await firstModuleHeader.click();
|
|
15
|
+
|
|
16
|
+
// Click the first lesson row in the now-expanded module.
|
|
17
|
+
const firstLessonRow = page.locator('aside nav ul ul button').first();
|
|
18
|
+
await firstLessonRow.click();
|
|
19
|
+
|
|
20
|
+
// URL must reflect the click; before v0.46.0 it stayed at "/".
|
|
21
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('dashboard "Start module" deep-links into a lesson', async ({ page }) => {
|
|
25
|
+
await page.goto('/');
|
|
26
|
+
const startBtn = page.getByRole('button', { name: /start module|continue/i }).first();
|
|
27
|
+
await startBtn.click();
|
|
28
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P9 regression: progress reactivity. Reaching a lesson page must mark
|
|
5
|
+
// it `in_progress` and the sidebar status icon must reflect that on the
|
|
6
|
+
// next sidebar render — without a page reload.
|
|
7
|
+
import { expect, test } from '@playwright/test';
|
|
8
|
+
|
|
9
|
+
test.describe('progress reactivity', () => {
|
|
10
|
+
test('navigating into a lesson marks it in_progress in the sidebar', async ({ page }) => {
|
|
11
|
+
await page.goto('/');
|
|
12
|
+
|
|
13
|
+
// Expand the first module and click into its first lesson.
|
|
14
|
+
await page.locator('aside nav button').first().click();
|
|
15
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
16
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
17
|
+
|
|
18
|
+
// Sidebar status icons: ○ = not_started, … = in_progress, ✓ = complete.
|
|
19
|
+
// After landing on the lesson page, the active row should not still
|
|
20
|
+
// show ○ (regression check; the previous bug left status forever ○).
|
|
21
|
+
const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
|
|
22
|
+
await expect(activeIcon).not.toHaveText('○');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// FR-P10 regression coverage: when navigating between two lessons that
|
|
5
|
+
// each contain a video block, only the new lesson's iframe should be
|
|
6
|
+
// present — the previous player must be destroyed. The {#key} wrapper
|
|
7
|
+
// in `[module]/[lesson]/+page.svelte` plus the stable block key in
|
|
8
|
+
// `LessonView` enforce this.
|
|
9
|
+
import { expect, test } from '@playwright/test';
|
|
10
|
+
|
|
11
|
+
test.describe('video block lifecycle', () => {
|
|
12
|
+
test('lesson page renders at most one YouTube iframe per video block', async ({ page }) => {
|
|
13
|
+
await page.goto('/');
|
|
14
|
+
await page.locator('aside nav button').first().click();
|
|
15
|
+
await page.locator('aside nav ul ul button').first().click();
|
|
16
|
+
await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
|
|
17
|
+
|
|
18
|
+
// Wait briefly for the YouTube IFrame API to upgrade the placeholder
|
|
19
|
+
// `<div id="yt-player-…">` into an `<iframe>`. If the API is blocked
|
|
20
|
+
// (no network in CI), there will be zero iframes — that's still a
|
|
21
|
+
// pass for "no leaked iframes from a prior lesson", which is what
|
|
22
|
+
// the regression cares about.
|
|
23
|
+
await page.waitForTimeout(2000);
|
|
24
|
+
const iframes = await page.locator('iframe[src*="youtube"]').count();
|
|
25
|
+
expect(iframes).toBeLessThanOrEqual(1);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
10
10
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
11
11
|
"test": "vitest run",
|
|
12
|
+
"e2e": "playwright test",
|
|
12
13
|
"postinstall": "node -e \"const fs=require('fs'); fs.mkdirSync('static',{recursive:true}); fs.copyFileSync(require.resolve('sql.js/dist/sql-wasm.wasm'),'static/sql-wasm.wasm')\""
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"svelte": "^5.0.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
25
|
+
"@playwright/test": "^1.48.0",
|
|
24
26
|
"@sveltejs/adapter-static": "^3.0.0",
|
|
25
27
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
26
28
|
"@tailwindcss/typography": "^0.5.16",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
4
|
+
|
|
5
|
+
const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 4173);
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
testDir: './e2e',
|
|
9
|
+
fullyParallel: false,
|
|
10
|
+
reporter: [['list']],
|
|
11
|
+
use: {
|
|
12
|
+
baseURL: `http://localhost:${PORT}`,
|
|
13
|
+
trace: 'retain-on-failure'
|
|
14
|
+
},
|
|
15
|
+
webServer: {
|
|
16
|
+
command: `pnpm preview --port ${PORT} --strictPort`,
|
|
17
|
+
port: PORT,
|
|
18
|
+
reuseExistingServer: !process.env.CI,
|
|
19
|
+
timeout: 60_000
|
|
20
|
+
},
|
|
21
|
+
projects: [
|
|
22
|
+
{
|
|
23
|
+
name: 'chromium',
|
|
24
|
+
use: { ...devices['Desktop Chrome'] }
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
|
-
import {
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { currentPosition } from '$lib/stores/curriculum.js';
|
|
4
5
|
import type { Lesson, LessonProgress } from '$lib/types/index.js';
|
|
5
6
|
import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
|
|
7
|
+
import { lessonHref } from './navigation.helpers.js';
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
moduleId: string;
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
|
|
35
37
|
function handleClick(lessonId: string) {
|
|
36
38
|
if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
|
|
37
|
-
|
|
39
|
+
void goto(lessonHref(moduleId, lessonId));
|
|
38
40
|
}
|
|
39
41
|
</script>
|
|
40
42
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Lesson, QuizScore } from '$lib/types/index.js';
|
|
7
7
|
import ContentBlock from './ContentBlock.svelte';
|
|
8
8
|
import Navigation from './Navigation.svelte';
|
|
9
|
+
import { contentBlockKey } from './lesson-view.helpers.js';
|
|
9
10
|
import { onMount } from 'svelte';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
</header>
|
|
66
67
|
|
|
67
68
|
<div class="space-y-8">
|
|
68
|
-
{#each lesson.content_blocks as block, i (i)}
|
|
69
|
+
{#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
|
|
69
70
|
<section>
|
|
70
71
|
<ContentBlock
|
|
71
72
|
{block}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright 2026 Pointmatic
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { createBlockTracker } from './lesson-view.helpers.js';
|
|
4
|
+
import { contentBlockKey, createBlockTracker } from './lesson-view.helpers.js';
|
|
5
|
+
import type { ContentBlock } from '$lib/types/index.js';
|
|
5
6
|
|
|
6
7
|
describe('LessonView block tracking (via createBlockTracker)', () => {
|
|
7
8
|
it('all blocks complete → returns true on final markBlockComplete', () => {
|
|
@@ -66,3 +67,51 @@ describe('LessonView block tracking (via createBlockTracker)', () => {
|
|
|
66
67
|
expect(tracker.allComplete).toBe(true);
|
|
67
68
|
});
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
describe('contentBlockKey (FR-P10 stable identity)', () => {
|
|
72
|
+
it('uses ref when present', () => {
|
|
73
|
+
const block = {
|
|
74
|
+
type: 'text',
|
|
75
|
+
source: null,
|
|
76
|
+
ref: 'content/mod-01/lesson-01.md',
|
|
77
|
+
content: { markdown: '', path: '' }
|
|
78
|
+
} as ContentBlock;
|
|
79
|
+
expect(contentBlockKey(block, 0)).toBe('text:content/mod-01/lesson-01.md');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses content.url for video blocks (no ref)', () => {
|
|
83
|
+
const block = {
|
|
84
|
+
type: 'video',
|
|
85
|
+
source: null,
|
|
86
|
+
ref: null,
|
|
87
|
+
content: { url: 'https://www.youtube.com/watch?v=abc' }
|
|
88
|
+
} as ContentBlock;
|
|
89
|
+
expect(contentBlockKey(block, 0)).toBe('video:https://www.youtube.com/watch?v=abc');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('falls back to type + index when neither ref nor url is present', () => {
|
|
93
|
+
const block = {
|
|
94
|
+
type: 'visualization',
|
|
95
|
+
source: null,
|
|
96
|
+
ref: null,
|
|
97
|
+
content: {}
|
|
98
|
+
} as unknown as ContentBlock;
|
|
99
|
+
expect(contentBlockKey(block, 2)).toBe('visualization-2');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('two video blocks with different URLs produce different keys', () => {
|
|
103
|
+
const a = {
|
|
104
|
+
type: 'video',
|
|
105
|
+
source: null,
|
|
106
|
+
ref: null,
|
|
107
|
+
content: { url: 'https://youtu.be/AAA' }
|
|
108
|
+
} as ContentBlock;
|
|
109
|
+
const b = {
|
|
110
|
+
type: 'video',
|
|
111
|
+
source: null,
|
|
112
|
+
ref: null,
|
|
113
|
+
content: { url: 'https://youtu.be/BBB' }
|
|
114
|
+
} as ContentBlock;
|
|
115
|
+
expect(contentBlockKey(a, 0)).not.toBe(contentBlockKey(b, 0));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import { goto } from '$app/navigation';
|
|
4
4
|
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
|
5
|
-
import { nextLesson, previousLesson
|
|
5
|
+
import { nextLesson, previousLesson } from '$lib/stores/curriculum.js';
|
|
6
|
+
import { resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
disabled?: boolean;
|
|
@@ -13,16 +14,13 @@
|
|
|
13
14
|
const next = $derived($nextLesson);
|
|
14
15
|
|
|
15
16
|
function goNext() {
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
navigateTo(next.moduleId, next.lessonId);
|
|
19
|
-
} else {
|
|
20
|
-
void goto('/');
|
|
21
|
-
}
|
|
17
|
+
const action = resolveGoNext(disabled, next);
|
|
18
|
+
if (action.kind === 'goto') void goto(action.url);
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
function goPrev() {
|
|
25
|
-
|
|
22
|
+
const action = resolveGoPrev(prev);
|
|
23
|
+
if (action.kind === 'goto') void goto(action.url);
|
|
26
24
|
}
|
|
27
25
|
</script>
|
|
28
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
|
-
import {
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
4
|
import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
|
|
5
5
|
import { getOptionalLessons, isModuleComplete } from '$lib/utils/locking.js';
|
|
6
6
|
import ProgressBar from './ProgressBar.svelte';
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
(l) => !optional.has(l.id) && mp?.lessons[l.id]?.status !== 'complete'
|
|
46
46
|
);
|
|
47
47
|
const target = firstIncomplete ?? mod.lessons[0];
|
|
48
|
-
if (target)
|
|
48
|
+
if (target) void goto(`/${mod.id}/${target.id}`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const totalLessons = $derived(modules.reduce((n, m) => n + m.lessons.length, 0));
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import type { VideoContent } from '$lib/types/index.js';
|
|
4
|
-
import { onMount } from 'svelte';
|
|
5
4
|
|
|
6
5
|
interface Props {
|
|
7
6
|
content: VideoContent;
|
|
@@ -51,11 +50,21 @@
|
|
|
51
50
|
};
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
// `$effect` re-runs whenever the watched URL changes, so when a parent
|
|
54
|
+
// reuses this component instance across lessons (e.g. two consecutive
|
|
55
|
+
// lessons each with a video block) we tear down the previous player
|
|
56
|
+
// and observer and create a fresh one for the new `videoId`. The
|
|
57
|
+
// {#key} wrapper at the lesson route plus the stable block key in
|
|
58
|
+
// `LessonView` already force a re-mount in normal flows; this effect
|
|
59
|
+
// is the belt-and-suspenders fallback if any caller skips both keys.
|
|
60
|
+
$effect(() => {
|
|
61
|
+
const url = content.url; // dependency
|
|
55
62
|
if (provider !== 'youtube' || !onvideocomplete) return;
|
|
56
|
-
const videoId = extractYouTubeId(
|
|
63
|
+
const videoId = extractYouTubeId(url);
|
|
57
64
|
if (!videoId) return;
|
|
58
65
|
|
|
66
|
+
fired = false;
|
|
67
|
+
|
|
59
68
|
let cleanup: (() => void) | undefined;
|
|
60
69
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
61
70
|
let player: any;
|
|
@@ -80,6 +89,8 @@
|
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
93
|
+
|
|
83
94
|
if ((window as any).YT?.Player) {
|
|
84
95
|
createPlayer();
|
|
85
96
|
} else {
|
|
@@ -95,21 +106,16 @@
|
|
|
95
106
|
createPlayer();
|
|
96
107
|
};
|
|
97
108
|
|
|
98
|
-
|
|
109
|
+
fallbackTimer = setTimeout(() => {
|
|
99
110
|
if (!(window as any).YT?.Player && !fired) {
|
|
100
111
|
cleanup = setupViewportFallback();
|
|
101
112
|
}
|
|
102
113
|
}, 5000);
|
|
103
|
-
|
|
104
|
-
return () => {
|
|
105
|
-
clearTimeout(fallbackTimer);
|
|
106
|
-
player?.destroy?.();
|
|
107
|
-
cleanup?.();
|
|
108
|
-
};
|
|
109
114
|
}
|
|
110
115
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
111
116
|
|
|
112
117
|
return () => {
|
|
118
|
+
if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
|
|
113
119
|
player?.destroy?.();
|
|
114
120
|
cleanup?.();
|
|
115
121
|
};
|
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
* unit-tested without mounting the Svelte component.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { ContentBlock } from '$lib/types/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable identity key for a content block. Used as the `{#each}` key so
|
|
13
|
+
* child components (notably `VideoBlock`, which holds an iframe player)
|
|
14
|
+
* tear down and re-mount when the underlying content changes rather
|
|
15
|
+
* than being reused across lessons with stale state.
|
|
16
|
+
*/
|
|
17
|
+
export function contentBlockKey(block: ContentBlock, index: number): string {
|
|
18
|
+
if (block.ref) return `${block.type}:${block.ref}`;
|
|
19
|
+
const url = (block.content as { url?: unknown })?.url;
|
|
20
|
+
if (typeof url === 'string') return `${block.type}:${url}`;
|
|
21
|
+
return `${block.type}-${index}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
export interface BlockTracker {
|
|
10
25
|
/** Record a block as complete. Returns true if this was the final block. */
|
|
11
26
|
markBlockComplete(blockIndex: number): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure resolver for the Next/Finish and Previous button actions.
|
|
4
|
+
*
|
|
5
|
+
* Extracted so the routing decision can be unit-tested without mounting
|
|
6
|
+
* `Navigation.svelte` and stubbing all of its store dependencies. The
|
|
7
|
+
* component just calls these helpers and dispatches the resulting `goto`
|
|
8
|
+
* call to `$app/navigation`.
|
|
9
|
+
*/
|
|
10
|
+
import type { NavPosition } from '$lib/stores/curriculum.js';
|
|
11
|
+
|
|
12
|
+
export type NavAction = { kind: 'noop' } | { kind: 'goto'; url: string };
|
|
13
|
+
|
|
14
|
+
/** Resolve the action for the Next/Finish button click. */
|
|
15
|
+
export function resolveGoNext(
|
|
16
|
+
disabled: boolean,
|
|
17
|
+
next: NavPosition | null
|
|
18
|
+
): NavAction {
|
|
19
|
+
if (disabled) return { kind: 'noop' };
|
|
20
|
+
if (next) return { kind: 'goto', url: `/${next.moduleId}/${next.lessonId}` };
|
|
21
|
+
return { kind: 'goto', url: '/' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve the action for the Previous button click. */
|
|
25
|
+
export function resolveGoPrev(prev: NavPosition | null): NavAction {
|
|
26
|
+
if (!prev) return { kind: 'noop' };
|
|
27
|
+
return { kind: 'goto', url: `/${prev.moduleId}/${prev.lessonId}` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** URL path for an in-app lesson link. */
|
|
31
|
+
export function lessonHref(moduleId: string, lessonId: string): string {
|
|
32
|
+
return `/${moduleId}/${lessonId}`;
|
|
33
|
+
}
|
learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { lessonHref, resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
|
|
5
|
+
|
|
6
|
+
describe('resolveGoNext (Next/Finish button)', () => {
|
|
7
|
+
it('routes to /{module}/{lesson} when there is a next lesson', () => {
|
|
8
|
+
const action = resolveGoNext(false, { moduleId: 'mod-01', lessonId: 'lesson-02' });
|
|
9
|
+
expect(action).toEqual({ kind: 'goto', url: '/mod-01/lesson-02' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('routes to / when next is null (Finish on last lesson)', () => {
|
|
13
|
+
const action = resolveGoNext(false, null);
|
|
14
|
+
expect(action).toEqual({ kind: 'goto', url: '/' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('disabled state is a no-op even with a next lesson', () => {
|
|
18
|
+
const action = resolveGoNext(true, { moduleId: 'mod-01', lessonId: 'lesson-02' });
|
|
19
|
+
expect(action).toEqual({ kind: 'noop' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('disabled state is a no-op when next is null', () => {
|
|
23
|
+
const action = resolveGoNext(true, null);
|
|
24
|
+
expect(action).toEqual({ kind: 'noop' });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('resolveGoPrev (Previous button)', () => {
|
|
29
|
+
it('routes to /{module}/{lesson} when there is a previous lesson', () => {
|
|
30
|
+
const action = resolveGoPrev({ moduleId: 'mod-01', lessonId: 'lesson-01' });
|
|
31
|
+
expect(action).toEqual({ kind: 'goto', url: '/mod-01/lesson-01' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('no-op when prev is null (first lesson)', () => {
|
|
35
|
+
expect(resolveGoPrev(null)).toEqual({ kind: 'noop' });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('lessonHref', () => {
|
|
40
|
+
it('formats /{module}/{lesson}', () => {
|
|
41
|
+
expect(lessonHref('mod-01', 'lesson-01')).toBe('/mod-01/lesson-01');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -86,6 +86,17 @@ export const nextLesson = derived(
|
|
|
86
86
|
// Navigation helpers
|
|
87
87
|
// ---------------------------------------------------------------------------
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* **Internal route-sync only — UI code must use `goto` directly.**
|
|
91
|
+
*
|
|
92
|
+
* Sets `currentPosition` and routes to `/${moduleId}/${lessonId}`. Used by the
|
|
93
|
+
* dynamic lesson route's URL→store `$effect` and by the legacy
|
|
94
|
+
* `navigateNext`/`navigatePrev` helpers; do **not** call from sidebars,
|
|
95
|
+
* dashboards, or navigation buttons. UI code should import `goto` from
|
|
96
|
+
* `$app/navigation` and build the path explicitly so SvelteKit's lifecycle
|
|
97
|
+
* (page params, scroll restoration, lesson-route `{#key}` re-mount) fires
|
|
98
|
+
* predictably for every user-initiated navigation.
|
|
99
|
+
*/
|
|
89
100
|
export function navigateTo(moduleId: string, lessonId: string): void {
|
|
90
101
|
currentPosition.set({ moduleId, lessonId });
|
|
91
102
|
void goto(`/${moduleId}/${lessonId}`);
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
</svelte:head>
|
|
32
32
|
|
|
33
33
|
{#if currentLesson && currentModule}
|
|
34
|
-
|
|
34
|
+
{#key `${currentModule.id}/${currentLesson.id}`}
|
|
35
|
+
<LessonView lesson={currentLesson} moduleId={currentModule.id} />
|
|
36
|
+
{/key}
|
|
35
37
|
{:else if $curriculum}
|
|
36
38
|
<div class="flex h-full items-center justify-center">
|
|
37
39
|
<p class="text-gray-400">Lesson not found.</p>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
|
-
import {
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { currentPosition } from '$lib/stores/curriculum.js';
|
|
4
5
|
import type { Lesson, LessonProgress } from '$lib/types/index.js';
|
|
5
6
|
import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
|
|
7
|
+
import { lessonHref } from './navigation.helpers.js';
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
moduleId: string;
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
|
|
35
37
|
function handleClick(lessonId: string) {
|
|
36
38
|
if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
|
|
37
|
-
|
|
39
|
+
void goto(lessonHref(moduleId, lessonId));
|
|
38
40
|
}
|
|
39
41
|
</script>
|
|
40
42
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Lesson, QuizScore } from '$lib/types/index.js';
|
|
7
7
|
import ContentBlock from './ContentBlock.svelte';
|
|
8
8
|
import Navigation from './Navigation.svelte';
|
|
9
|
+
import { contentBlockKey } from './lesson-view.helpers.js';
|
|
9
10
|
import { onMount } from 'svelte';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
</header>
|
|
66
67
|
|
|
67
68
|
<div class="space-y-8">
|
|
68
|
-
{#each lesson.content_blocks as block, i (i)}
|
|
69
|
+
{#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
|
|
69
70
|
<section>
|
|
70
71
|
<ContentBlock
|
|
71
72
|
{block}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import { goto } from '$app/navigation';
|
|
4
4
|
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
|
5
|
-
import { nextLesson, previousLesson
|
|
5
|
+
import { nextLesson, previousLesson } from '$lib/stores/curriculum.js';
|
|
6
|
+
import { resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
disabled?: boolean;
|
|
@@ -13,16 +14,13 @@
|
|
|
13
14
|
const next = $derived($nextLesson);
|
|
14
15
|
|
|
15
16
|
function goNext() {
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
navigateTo(next.moduleId, next.lessonId);
|
|
19
|
-
} else {
|
|
20
|
-
void goto('/');
|
|
21
|
-
}
|
|
17
|
+
const action = resolveGoNext(disabled, next);
|
|
18
|
+
if (action.kind === 'goto') void goto(action.url);
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
function goPrev() {
|
|
25
|
-
|
|
22
|
+
const action = resolveGoPrev(prev);
|
|
23
|
+
if (action.kind === 'goto') void goto(action.url);
|
|
26
24
|
}
|
|
27
25
|
</script>
|
|
28
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
|
-
import {
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
4
|
import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
|
|
5
5
|
import { getOptionalLessons, isModuleComplete } from '$lib/utils/locking.js';
|
|
6
6
|
import ProgressBar from './ProgressBar.svelte';
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
(l) => !optional.has(l.id) && mp?.lessons[l.id]?.status !== 'complete'
|
|
46
46
|
);
|
|
47
47
|
const target = firstIncomplete ?? mod.lessons[0];
|
|
48
|
-
if (target)
|
|
48
|
+
if (target) void goto(`/${mod.id}/${target.id}`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const totalLessons = $derived(modules.reduce((n, m) => n + m.lessons.length, 0));
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
|
|
2
2
|
<script lang="ts">
|
|
3
3
|
import type { VideoContent } from '$lib/types/index.js';
|
|
4
|
-
import { onMount } from 'svelte';
|
|
5
4
|
|
|
6
5
|
interface Props {
|
|
7
6
|
content: VideoContent;
|
|
@@ -51,11 +50,21 @@
|
|
|
51
50
|
};
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
// `$effect` re-runs whenever the watched URL changes, so when a parent
|
|
54
|
+
// reuses this component instance across lessons (e.g. two consecutive
|
|
55
|
+
// lessons each with a video block) we tear down the previous player
|
|
56
|
+
// and observer and create a fresh one for the new `videoId`. The
|
|
57
|
+
// {#key} wrapper at the lesson route plus the stable block key in
|
|
58
|
+
// `LessonView` already force a re-mount in normal flows; this effect
|
|
59
|
+
// is the belt-and-suspenders fallback if any caller skips both keys.
|
|
60
|
+
$effect(() => {
|
|
61
|
+
const url = content.url; // dependency
|
|
55
62
|
if (provider !== 'youtube' || !onvideocomplete) return;
|
|
56
|
-
const videoId = extractYouTubeId(
|
|
63
|
+
const videoId = extractYouTubeId(url);
|
|
57
64
|
if (!videoId) return;
|
|
58
65
|
|
|
66
|
+
fired = false;
|
|
67
|
+
|
|
59
68
|
let cleanup: (() => void) | undefined;
|
|
60
69
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
61
70
|
let player: any;
|
|
@@ -80,6 +89,8 @@
|
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
93
|
+
|
|
83
94
|
if ((window as any).YT?.Player) {
|
|
84
95
|
createPlayer();
|
|
85
96
|
} else {
|
|
@@ -95,21 +106,16 @@
|
|
|
95
106
|
createPlayer();
|
|
96
107
|
};
|
|
97
108
|
|
|
98
|
-
|
|
109
|
+
fallbackTimer = setTimeout(() => {
|
|
99
110
|
if (!(window as any).YT?.Player && !fired) {
|
|
100
111
|
cleanup = setupViewportFallback();
|
|
101
112
|
}
|
|
102
113
|
}, 5000);
|
|
103
|
-
|
|
104
|
-
return () => {
|
|
105
|
-
clearTimeout(fallbackTimer);
|
|
106
|
-
player?.destroy?.();
|
|
107
|
-
cleanup?.();
|
|
108
|
-
};
|
|
109
114
|
}
|
|
110
115
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
111
116
|
|
|
112
117
|
return () => {
|
|
118
|
+
if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
|
|
113
119
|
player?.destroy?.();
|
|
114
120
|
cleanup?.();
|
|
115
121
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* LessonView block-tracking helpers.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `LessonView.svelte` so the completion logic can be
|
|
6
|
+
* unit-tested without mounting the Svelte component.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ContentBlock } from '$lib/types/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable identity key for a content block. Used as the `{#each}` key so
|
|
13
|
+
* child components (notably `VideoBlock`, which holds an iframe player)
|
|
14
|
+
* tear down and re-mount when the underlying content changes rather
|
|
15
|
+
* than being reused across lessons with stale state.
|
|
16
|
+
*/
|
|
17
|
+
export function contentBlockKey(block: ContentBlock, index: number): string {
|
|
18
|
+
if (block.ref) return `${block.type}:${block.ref}`;
|
|
19
|
+
const url = (block.content as { url?: unknown })?.url;
|
|
20
|
+
if (typeof url === 'string') return `${block.type}:${url}`;
|
|
21
|
+
return `${block.type}-${index}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BlockTracker {
|
|
25
|
+
/** Record a block as complete. Returns true if this was the final block. */
|
|
26
|
+
markBlockComplete(blockIndex: number): boolean;
|
|
27
|
+
/** Number of completed blocks so far. */
|
|
28
|
+
readonly completedCount: number;
|
|
29
|
+
/** Whether all blocks are complete. */
|
|
30
|
+
readonly allComplete: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a block tracker for a lesson with `totalBlocks` content blocks.
|
|
35
|
+
*
|
|
36
|
+
* If `preComplete` is true, the lesson was already marked complete in the DB
|
|
37
|
+
* on a previous visit — all blocks are considered done from the start.
|
|
38
|
+
*
|
|
39
|
+
* Zero-block lessons are immediately complete.
|
|
40
|
+
*/
|
|
41
|
+
export function createBlockTracker(
|
|
42
|
+
totalBlocks: number,
|
|
43
|
+
preComplete: boolean = false
|
|
44
|
+
): BlockTracker {
|
|
45
|
+
const completed = new Set<number>();
|
|
46
|
+
const immediate = totalBlocks === 0 || preComplete;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
markBlockComplete(blockIndex: number): boolean {
|
|
50
|
+
if (immediate) return true;
|
|
51
|
+
completed.add(blockIndex);
|
|
52
|
+
return completed.size === totalBlocks;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
get completedCount(): number {
|
|
56
|
+
return immediate ? totalBlocks : completed.size;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
get allComplete(): boolean {
|
|
60
|
+
return immediate || completed.size === totalBlocks;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure resolver for the Next/Finish and Previous button actions.
|
|
4
|
+
*
|
|
5
|
+
* Extracted so the routing decision can be unit-tested without mounting
|
|
6
|
+
* `Navigation.svelte` and stubbing all of its store dependencies. The
|
|
7
|
+
* component just calls these helpers and dispatches the resulting `goto`
|
|
8
|
+
* call to `$app/navigation`.
|
|
9
|
+
*/
|
|
10
|
+
import type { NavPosition } from '$lib/stores/curriculum.js';
|
|
11
|
+
|
|
12
|
+
export type NavAction = { kind: 'noop' } | { kind: 'goto'; url: string };
|
|
13
|
+
|
|
14
|
+
/** Resolve the action for the Next/Finish button click. */
|
|
15
|
+
export function resolveGoNext(
|
|
16
|
+
disabled: boolean,
|
|
17
|
+
next: NavPosition | null
|
|
18
|
+
): NavAction {
|
|
19
|
+
if (disabled) return { kind: 'noop' };
|
|
20
|
+
if (next) return { kind: 'goto', url: `/${next.moduleId}/${next.lessonId}` };
|
|
21
|
+
return { kind: 'goto', url: '/' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve the action for the Previous button click. */
|
|
25
|
+
export function resolveGoPrev(prev: NavPosition | null): NavAction {
|
|
26
|
+
if (!prev) return { kind: 'noop' };
|
|
27
|
+
return { kind: 'goto', url: `/${prev.moduleId}/${prev.lessonId}` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** URL path for an in-app lesson link. */
|
|
31
|
+
export function lessonHref(moduleId: string, lessonId: string): string {
|
|
32
|
+
return `/${moduleId}/${lessonId}`;
|
|
33
|
+
}
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/curriculum.ts
RENAMED
|
@@ -85,6 +85,16 @@ export const nextLesson = derived(
|
|
|
85
85
|
// Navigation helpers
|
|
86
86
|
// ---------------------------------------------------------------------------
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* **Internal route-sync only — UI code must use `goto` directly.**
|
|
90
|
+
*
|
|
91
|
+
* Sets `currentPosition`. Used by the dynamic lesson route's URL→store
|
|
92
|
+
* `$effect`; do **not** call from sidebars, dashboards, or navigation
|
|
93
|
+
* buttons. UI code should import `goto` from `$app/navigation` and build
|
|
94
|
+
* the path explicitly so SvelteKit's lifecycle (page params, scroll
|
|
95
|
+
* restoration, lesson-route `{#key}` re-mount) fires predictably for
|
|
96
|
+
* every user-initiated navigation.
|
|
97
|
+
*/
|
|
88
98
|
export function navigateTo(moduleId: string, lessonId: string): void {
|
|
89
99
|
currentPosition.set({ moduleId, lessonId });
|
|
90
100
|
}
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
</svelte:head>
|
|
32
32
|
|
|
33
33
|
{#if currentLesson && currentModule}
|
|
34
|
-
|
|
34
|
+
{#key `${currentModule.id}/${currentLesson.id}`}
|
|
35
|
+
<LessonView lesson={currentLesson} moduleId={currentModule.id} />
|
|
36
|
+
{/key}
|
|
35
37
|
{:else if $curriculum}
|
|
36
38
|
<div class="flex h-full items-center justify-center">
|
|
37
39
|
<p class="text-gray-400">Lesson not found.</p>
|
|
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.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/__init__.py
RENAMED
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/d3foundry_stub.py
RENAMED
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/nbfoundry_stub.py
RENAMED
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/protocols.py
RENAMED
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/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
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/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
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/progress.ts
RENAMED
|
File without changes
|
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/locking.ts
RENAMED
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/markdown.ts
RENAMED
|
File without changes
|
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+layout.svelte
RENAMED
|
File without changes
|
{learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+page.svelte
RENAMED
|
File without changes
|