@mostajs/course-builder 0.1.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog — @mostajs/course-builder
2
+ ## [0.1.0] — 2026-06-18
3
+ ### Added
4
+ - Domaine éditeur (extraction iquesta) : arbre Formation/Activity (10 kinds), dépendances DAG + détection de cycle (DFS), validation par kind, snapshots versionnés + intégrité sha256 (compose @mostajs/security).
5
+ - DÉCOUPLÉ du média (mediaRef FK opaque) — intégration média documentée (composition) dans docs/12-DOC-MEDIA-INTEGRATION.md. 6 tests + exemple §12.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @mostajs/course-builder
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Statut** : 0.1.0 (6 tests verts)
3
+ > Éditeur de formation (domaine headless) : arbre, **dépendances DAG + détection de cycle**, validation par kind, **snapshots versionnés**.
4
+ > **Découplé du média** (`mediaRef` FK opaque) — intégration média = composition (`docs/12-DOC-MEDIA-INTEGRATION.md`).
5
+ ```js
6
+ import { createCourseBuilder, createMemoryRepositories } from '@mostajs/course-builder';
7
+ const cb = createCourseBuilder({ repositories: createMemoryRepositories(), hasher });
8
+ const f = await cb.formations.create({ title:'Anglais A1' });
9
+ const v = await cb.activities.add(f.id, { kind:'video', title:'Hello', mediaRef:'MEDIA-ID' }); // FK opaque → @mostajs/media
10
+ await cb.deps.add(f.id, quizId, v.id); // throw CycleDetected si cycle
11
+ const snap = await cb.snapshots.create(f.id, { label:'v1.0' });
12
+ ```
13
+ Lancer : `node test-scripts/unit/course-builder.test.mjs && node examples/cours/run.mjs`
@@ -0,0 +1,16 @@
1
+ # @mostajs/course-builder — DEVRULES de bout en bout (§4 + #3 + #4 condensés)
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Date** : 2026-06-18 · **Statut** : cas C (P1+P2), extraction iquesta.
3
+ **Décision d'ordonnancement (validée)** : **EXTRAIRE + TESTER le domaine d'abord, intégrer le média ENSUITE** (composition).
4
+ Justification : `Activity.mediaRef` est une **FK opaque** → le domaine (arbre, dépendances DAG, validation, snapshots) est
5
+ **découplé** du média ; `@mostajs/media`+`media-server`+`storage` sont **déjà publiés** (composables) ; le domaine est
6
+ **testable headless**, l'UI média (recorder/editor) est navigateur (intégrée après, cf. `12-DOC-MEDIA-INTEGRATION.md`).
7
+ ## §4 (règle d'or)
8
+ Le **modèle de formation** (Formation/Activity/Dependency/Snapshot) n'existe nulle part en module ; `training`=catalogue/sessions
9
+ (≠ structure de contenu) ; `elearning`=progression apprenant. → cas C : l'ÉDITEUR/structure de cours. Média/quiz/contenu **délégués** par FK.
10
+ ## §3 (périmètre/API)
11
+ **Entités** : Formation, Activity(10 kinds), ActivityDependency(DAG), FormationSnapshot(versioning).
12
+ **API** : `createCourseBuilder({repositories,hasher?})` → `formations.{create,get,list,publish}`, `activities.{add,update,remove,list,tree}`,
13
+ `deps.{add(cycle-safe),list,wouldCycle}`, `validate(formationId)`, `snapshots.{create,list,verify}`.
14
+ **Algos extraits** : détection de cycle (DFS), validation par kind, snapshot bundle+sha256 (compose @mostajs/security).
15
+ **Compose (§10)** : security (sha256 snapshot) ; **délègue** média→@mostajs/media (mediaRef), quiz→@mostajs/questa (quizRef), contenu→storage. **Jalons** : 0.1 domaine+algos · 0.2 intégration média (composition) · 0.3 éditeur React (UI) · 1.0 14 livrables.
16
+ ## §4 (tests) : arbre, validation kind, cycle, snapshot+intégrité, mediaRef opaque. (6 verts)
@@ -0,0 +1,37 @@
1
+ # 12 — Intégration MÉDIA dans @mostajs/course-builder (composition)
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Date** : 2026-06-18
3
+ **Source** : généralisé de `SolutionCh/iquesta/docs/MEDIA-INTEGRATION-COURSE-BUILDER.md` + `PLAN-MEDIA-STORAGE-COURSE-BUILDER-16052026.md`.
4
+ **Principe** : le média n'est PAS dans course-builder. `Activity.mediaRef` est une **FK opaque** vers une row `Media` de
5
+ **@mostajs/media** (qui délègue les octets à **@mostajs/storage**). L'intégration = un **slot UI** + une **route d'upload** ;
6
+ aucune dépendance média dans le cœur (qui reste headless/DB-agnostique).
7
+
8
+ ## Flux (auteur enregistre une vidéo de cours)
9
+ ```
10
+ <PropsPanel kind='video'> → <CourseBuilderMediaSlot> (UI, navigateur)
11
+ ├─ <MediaPicker filter={mimePrefix}> → onSelect(media.id) (média existant)
12
+ └─ <MultiTakeRecorder> → <VideoEditor> → export blob
13
+ → POST /api/admin/media/recordings (multipart)
14
+ ↳ @mostajs/media écrit la row Media + délègue bytes à @mostajs/storage
15
+ (bucket 'media-recordings', sha256=checksum storage, signed URL)
16
+ ↳ { mediaId, signedUrl }
17
+ → courseBuilder.activities.update(activityId, { mediaRef: mediaId }) ← SEUL contact avec le cœur
18
+ ```
19
+
20
+ ## Étapes (M1–M5, généralisées)
21
+ | # | Étape | Côté |
22
+ |---|---|---|
23
+ | M1 | deps `@mostajs/media` (+ `media-server`) + `@mostajs/storage` (déjà publiés/éprouvés) | app |
24
+ | M2 | bucket `media-recordings` (+ `media-thumbnails`) provisionné (storage) | infra/.env |
25
+ | M3 | route `POST /api/admin/media/recordings` (upload) + `GET /api/admin/media/[id]` | app (compose media+storage) |
26
+ | M4 | `<CourseBuilderMediaSlot>` : `<MediaPicker>` + `<MultiTakeRecorder>` + `<VideoEditor>` → `update({mediaRef})` | app (compose @mostajs/media) |
27
+ | M5 | *(stretch)* kind `live` → `liveRef` (SFU/WHIP via media-stack) | app |
28
+
29
+ ## Contrat avec le cœur (course-builder)
30
+ - `activities.add/update(... , { mediaRef })` : le cœur **stocke et valide** seulement la présence de `mediaRef`
31
+ (cf. `validateActivity` : `video`/`audio` ⇒ `mediaRef` requis). Il **n'ouvre jamais** la caméra, ne lit jamais les octets.
32
+ - `quizRef` → @mostajs/questa (projet quiz) ; `contentMarkdown`/`externalUrl` → reading/external ; `liveRef` → live.
33
+ - **Conséquence** : le cœur reste testable sans navigateur ; l'intégration média se teste côté app (E2E navigateur).
34
+
35
+ ## Pourquoi ce découplage (rappel DEVRULES §10)
36
+ Média = capacité lourde (caméra/WebRTC/transcodage) **déjà modularisée** (@mostajs/media). Le course-builder la **compose
37
+ par référence** (mediaRef), il ne la **réimplémente ni ne l'embarque**. Idem sha256 (storage/security), quiz (questa).
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createCourseBuilder, createMemoryRepositories } from '../../src/index.js';
3
+ import { sha256 } from '../../../mosta-security/src/index.js';
4
+ const cb=createCourseBuilder({ repositories:createMemoryRepositories(), hasher:sha256 });
5
+ const f=await cb.formations.create({ title:'Anglais A1', slug:'eng-a1' });
6
+ const m1=await cb.activities.add(f.id,{ kind:'module', title:'Salutations', order:1 });
7
+ const l1=await cb.activities.add(f.id,{ kind:'video', title:'Vidéo: Hello', parentId:m1.id, order:1, mediaRef:'MEDIA-OPAQUE-1' });
8
+ const l2=await cb.activities.add(f.id,{ kind:'quiz', title:'Quiz salutations', parentId:m1.id, order:2, quizRef:'QUESTA-PROJ-1' });
9
+ await cb.deps.add(f.id, l2.id, l1.id); // le quiz dépend de la vidéo
10
+ const v=await cb.validate(f.id); assert.equal(v.ok,true);
11
+ const snap=await cb.snapshots.create(f.id,{ label:'v1.0' });
12
+ const tree=await cb.activities.tree(f.id);
13
+ console.log('✅ course-builder — « %s » : %d module(s), snapshot %s (intègre? %s) · média=FK opaque (%s), quiz→questa (%s)',
14
+ f.title, tree.length, snap.label, await cb.snapshots.verify(snap.id), l1.mediaRef, l2.quizRef);
package/llms.txt ADDED
@@ -0,0 +1,16 @@
1
+ # @mostajs/course-builder — fiche LLM
2
+ RÔLE
3
+ Éditeur de formation (domaine headless) : arbre Formation/Activity (10 kinds), dépendances DAG + détection de cycle,
4
+ validation par kind, snapshots versionnés. DÉCOUPLÉ du média (mediaRef opaque). Extraction iquesta. DB-agnostique.
5
+ Compose (injectés) : security (sha256 snapshot). Délègue : média→@mostajs/media (mediaRef), quiz→@mostajs/questa (quizRef), contenu→storage.
6
+ EXPORTS
7
+ createCourseBuilder({ repositories, hasher?, now? }) -> { formations, activities, deps, validate, snapshots }
8
+ validateActivity(a) ; ACTIVITY_KINDS ; createMemoryRepositories(); Formation/Activity/ActivityDependency/FormationSnapshotSchema
9
+ API
10
+ formations.create/get/list/publish ; activities.add/update/remove/list/tree(formationId)
11
+ deps.add(formationId,fromId,toId)[throw CycleDetected]/list/wouldCycle ; validate(formationId)->{ok,issues}
12
+ snapshots.create(formationId,{label})/list/get/verify(id)
13
+ KINDS: module|lesson|video|audio|reading|quiz|submission|live|external|certificate
14
+ PIÈGES
15
+ - mediaRef/quizRef/liveRef = FK OPAQUES (média = composition @mostajs/media, cf. docs/12-DOC-MEDIA-INTEGRATION.md). Ne pas embarquer la caméra.
16
+ - deps.add refuse tout cycle (DFS). snapshot.verify recalcule sha256 (compose @mostajs/security). validation : video/audio⇒mediaRef, quiz⇒quizRef.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@mostajs/course-builder",
3
+ "version": "0.1.0",
4
+ "description": "Éditeur de formation (domaine headless) : arbre Formation/Activity (10 kinds), dépendances DAG + détection de cycle, validation par kind, snapshots versionnés. DÉCOUPLÉ du média (mediaRef opaque). Extraction iquesta.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./schemas": "./src/schemas.js"
12
+ },
13
+ "keywords": [
14
+ "mostajs",
15
+ "course-builder",
16
+ "formation",
17
+ "lms",
18
+ "dag",
19
+ "snapshot"
20
+ ],
21
+ "scripts": {
22
+ "test": "node test-scripts/unit/course-builder.test.mjs",
23
+ "example": "node examples/cours/run.mjs"
24
+ }
25
+ }
@@ -0,0 +1,94 @@
1
+ // @mostajs/course-builder — domaine éditeur de formation (headless). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // EXTRACTION du modèle/algos iquesta (Formation/Activity/Dependency/Snapshot). DÉCOUPLÉ du média :
3
+ // Activity.mediaRef / quizRef / liveRef sont des FK OPAQUES (média = composition @mostajs/media, cf. doc 12-DOC-MEDIA).
4
+ // Compose (injectés) : hasher (snapshot sha256 — @mostajs/security). Délègue média→media, quiz→questa, contenu→storage.
5
+
6
+ export const ACTIVITY_KINDS = ['module', 'lesson', 'video', 'audio', 'reading', 'quiz', 'submission', 'live', 'external', 'certificate'];
7
+
8
+ const simpleHash = (s) => { let h = 0; const t = String(s); for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0; return 'h' + (h >>> 0).toString(16); };
9
+
10
+ /** Validation par kind (mediaRef pour video/audio, quizRef pour quiz, etc.). */
11
+ export function validateActivity(a) {
12
+ const errs = [];
13
+ if (!a.title) errs.push('title requis');
14
+ if (!ACTIVITY_KINDS.includes(a.kind)) errs.push(`kind invalide: ${a.kind}`);
15
+ if (['video', 'audio'].includes(a.kind) && !a.mediaRef) errs.push(`mediaRef requis (${a.kind})`);
16
+ if (a.kind === 'quiz' && !a.quizRef) errs.push('quizRef requis (quiz → @mostajs/questa)');
17
+ if (a.kind === 'reading' && !a.contentMarkdown) errs.push('contentMarkdown requis (reading)');
18
+ if (a.kind === 'external' && !a.externalUrl) errs.push('externalUrl requis (external)');
19
+ return errs;
20
+ }
21
+
22
+ export function createCourseBuilder({ repositories, hasher, now = () => new Date() } = {}) {
23
+ if (!repositories?.formations) throw new Error('createCourseBuilder: repositories requis');
24
+ const { formations, activities, dependencies, snapshots } = repositories;
25
+ const hash = (s) => (hasher ? hasher(s) : simpleHash(s));
26
+
27
+ // détection de cycle (DFS) sur le graphe de dépendances + l'arête candidate from→to
28
+ async function wouldCycle(formationId, fromId, toId) {
29
+ if (fromId === toId) return true;
30
+ const deps = await dependencies.find((d) => d.formationId === formationId);
31
+ const adj = {}; for (const d of deps) (adj[d.fromId] ||= []).push(d.toId);
32
+ (adj[fromId] ||= []).push(toId);
33
+ const color = {}; const nodes = new Set();
34
+ for (const k of Object.keys(adj)) { nodes.add(k); for (const v of adj[k]) nodes.add(v); }
35
+ const dfs = (u) => { color[u] = 1; for (const v of (adj[u] || [])) { if (color[v] === 1) return true; if (!color[v] && dfs(v)) return true; } color[u] = 2; return false; };
36
+ for (const n of nodes) if (!color[n] && dfs(n)) return true;
37
+ return false;
38
+ }
39
+
40
+ const api = {
41
+ formations: {
42
+ create: (dto) => formations.create({ status: 'draft', version: 'v1.0', ...dto }),
43
+ get: (id) => formations.findById(id),
44
+ list: (f = {}) => formations.find((x) => Object.entries(f).every(([k, v]) => x[k] === v)),
45
+ publish: (id) => formations.update(id, { status: 'published', publishedAt: now() }),
46
+ },
47
+ activities: {
48
+ add: (formationId, dto) => activities.create({ formationId, kind: 'lesson', order: 0, ...dto }),
49
+ update: (id, patch) => activities.update(id, patch),
50
+ remove: (id) => activities.remove ? activities.remove(id) : activities.update(id, { _deleted: true }),
51
+ list: (formationId) => activities.find((a) => a.formationId === formationId && !a._deleted).then((x) => x.sort((p, q) => (p.order || 0) - (q.order || 0))),
52
+ /** Arbre imbriqué par parentId. */
53
+ async tree(formationId) {
54
+ const flat = await api.activities.list(formationId);
55
+ const byParent = {}; for (const a of flat) (byParent[a.parentId || null] ||= []).push(a);
56
+ const build = (pid) => (byParent[pid] || []).map((a) => ({ ...a, children: build(a.id) }));
57
+ return build(null);
58
+ },
59
+ },
60
+ deps: {
61
+ /** Ajoute une dépendance from→to (from requiert to). Refuse si cycle. */
62
+ async add(formationId, fromId, toId) {
63
+ if (await wouldCycle(formationId, fromId, toId)) throw new Error('CycleDetected: dépendance créerait un cycle');
64
+ return dependencies.create({ formationId, fromId, toId });
65
+ },
66
+ list: (formationId) => dependencies.find((d) => d.formationId === formationId),
67
+ wouldCycle: (formationId, fromId, toId) => wouldCycle(formationId, fromId, toId),
68
+ },
69
+ /** Validation globale (issues par activité). */
70
+ async validate(formationId) {
71
+ const acts = await api.activities.list(formationId);
72
+ const issues = [];
73
+ for (const a of acts) { const e = validateActivity(a); if (e.length) issues.push({ activityId: a.id, code: a.code || a.title, errors: e }); }
74
+ return { ok: issues.length === 0, issues };
75
+ },
76
+ snapshots: {
77
+ /** Snapshot versionné (bundle JSON + sha256 via @mostajs/security injecté). */
78
+ async create(formationId, { label } = {}) {
79
+ const formation = await formations.findById(formationId);
80
+ const acts = await api.activities.list(formationId);
81
+ const deps = await api.deps.list(formationId);
82
+ const bundle = { formation, activities: acts, dependencies: deps, at: now() };
83
+ const bundleJson = JSON.stringify(bundle);
84
+ return snapshots.create({ formationId, label: label || formation?.version, bundleJson, sha256: hash(bundleJson), at: now() });
85
+ },
86
+ list: (formationId) => snapshots.find((s) => s.formationId === formationId),
87
+ get: (id) => snapshots.findById(id),
88
+ /** Intégrité : recalcule le hash. */
89
+ async verify(id) { const s = await snapshots.findById(id); return !!s && s.sha256 === hash(s.bundleJson); },
90
+ },
91
+ repositories,
92
+ };
93
+ return api;
94
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @mostajs/course-builder — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ export { createCourseBuilder, validateActivity, ACTIVITY_KINDS } from './course-builder.js';
3
+ export { createMemoryRepositories } from './memory-repo.js';
4
+ export { FormationSchema, ActivitySchema, ActivityDependencySchema, FormationSnapshotSchema } from './schemas.js';
@@ -0,0 +1,7 @@
1
+ function coll(){ const m=new Map(); return {
2
+ async create(d){ const id=d.id||globalThis.crypto.randomUUID(); const now=new Date(); const r={id,createdAt:now,updatedAt:now,...d}; m.set(id,r); return {...r}; },
3
+ async findById(id){ const r=m.get(id); return r?{...r}:null; },
4
+ async update(id,p){ const r=m.get(id); if(!r)return null; const x={...r,...p,updatedAt:new Date()}; m.set(id,x); return {...x}; },
5
+ async remove(id){ return m.delete(id); },
6
+ async find(f=()=>true){ return [...m.values()].filter(f).map(r=>({...r})); } }; }
7
+ export function createMemoryRepositories(){ return { formations:coll(), activities:coll(), dependencies:coll(), snapshots:coll() }; }
package/src/schemas.js ADDED
@@ -0,0 +1,13 @@
1
+ // @mostajs/course-builder — schémas (EntitySchema @mostajs/orm). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ export const FormationSchema = { name:'Formation', collection:'formations', timestamps:true, fields:{
3
+ title:{type:'string',required:true}, slug:{type:'string'}, version:{type:'string',default:'v1.0'},
4
+ status:{type:'string',enum:['draft','published'],default:'draft'}, publishedAt:{type:'date'} } };
5
+ export const ActivitySchema = { name:'Activity', collection:'activities', timestamps:true, fields:{
6
+ formationId:{type:'string',required:true}, parentId:{type:'string',default:null}, code:{type:'string'},
7
+ title:{type:'string',required:true}, kind:{type:'string',required:true}, order:{type:'number',default:0},
8
+ mediaRef:{type:'string',default:null}, quizRef:{type:'string',default:null}, liveRef:{type:'string',default:null},
9
+ contentMarkdown:{type:'string'}, externalUrl:{type:'string'} }, indexes:[{fields:{formationId:'asc'}}] };
10
+ export const ActivityDependencySchema = { name:'ActivityDependency', collection:'activity_dependencies', timestamps:true, fields:{
11
+ formationId:{type:'string',required:true}, fromId:{type:'string',required:true}, toId:{type:'string',required:true} }, indexes:[{fields:{formationId:'asc'}}] };
12
+ export const FormationSnapshotSchema = { name:'FormationSnapshot', collection:'formation_snapshots', timestamps:true, fields:{
13
+ formationId:{type:'string',required:true}, label:{type:'string'}, bundleJson:{type:'text'}, sha256:{type:'string'}, at:{type:'date'} }, indexes:[{fields:{formationId:'asc'}}] };
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createCourseBuilder, createMemoryRepositories, validateActivity } from '../../src/index.js';
3
+ import { sha256 } from '../../../mosta-security/src/index.js'; // snapshot sha256 = @mostajs/security
4
+ let pass=0; const test=async(n,f)=>{await f();pass++;console.log(' ✓',n);};
5
+ const mk=(o={})=>createCourseBuilder({ repositories:createMemoryRepositories(), hasher:sha256, ...o });
6
+
7
+ await test('formation + arbre (module→leçon→activité)', async()=>{
8
+ const cb=mk(); const f=await cb.formations.create({title:'Anglais A1'});
9
+ const mod=await cb.activities.add(f.id,{kind:'module',title:'Module 1',order:1});
10
+ await cb.activities.add(f.id,{kind:'lesson',title:'Leçon 1',parentId:mod.id,order:1});
11
+ const tree=await cb.activities.tree(f.id);
12
+ assert.equal(tree.length,1); assert.equal(tree[0].children.length,1);
13
+ });
14
+ await test('validation par kind (mediaRef/quizRef/...)', ()=>{
15
+ assert.deepEqual(validateActivity({title:'V',kind:'video'}).filter(e=>/mediaRef/.test(e)).length,1);
16
+ assert.equal(validateActivity({title:'V',kind:'video',mediaRef:'m1'}).length,0);
17
+ assert.equal(validateActivity({title:'Q',kind:'quiz',quizRef:'qz1'}).length,0);
18
+ });
19
+ await test('validate(formation) remonte les issues', async()=>{
20
+ const cb=mk(); const f=await cb.formations.create({title:'F'});
21
+ await cb.activities.add(f.id,{kind:'video',title:'Sans média'}); // mediaRef manquant
22
+ const v=await cb.validate(f.id); assert.equal(v.ok,false); assert.match(v.issues[0].errors[0],/mediaRef/);
23
+ });
24
+ await test('dépendances DAG + DÉTECTION DE CYCLE', async()=>{
25
+ const cb=mk(); const f=await cb.formations.create({title:'F'});
26
+ const a=await cb.activities.add(f.id,{kind:'lesson',title:'A'});
27
+ const b=await cb.activities.add(f.id,{kind:'lesson',title:'B'});
28
+ const c=await cb.activities.add(f.id,{kind:'lesson',title:'C'});
29
+ await cb.deps.add(f.id,a.id,b.id); // A→B
30
+ await cb.deps.add(f.id,b.id,c.id); // B→C
31
+ await assert.rejects(()=>cb.deps.add(f.id,c.id,a.id),/CycleDetected/); // C→A créerait A→B→C→A
32
+ assert.equal(await cb.deps.wouldCycle(f.id,a.id,a.id),true); // auto-dépendance
33
+ });
34
+ await test('snapshot versionné + intégrité (sha256 via security)', async()=>{
35
+ const cb=mk(); const f=await cb.formations.create({title:'F'}); await cb.activities.add(f.id,{kind:'lesson',title:'L'});
36
+ const s=await cb.snapshots.create(f.id,{label:'v1.0'});
37
+ assert.ok(s.sha256.startsWith('sha256:')); assert.equal(await cb.snapshots.verify(s.id),true);
38
+ await cb.repositories.snapshots.update(s.id,{bundleJson:'{falsifié}'});
39
+ assert.equal(await cb.snapshots.verify(s.id),false); // altération détectée
40
+ });
41
+ await test('mediaRef OPAQUE (découplage média)', async()=>{
42
+ const cb=mk(); const f=await cb.formations.create({title:'F'});
43
+ const v=await cb.activities.add(f.id,{kind:'video',title:'Cours vidéo',mediaRef:'media-id-123'});
44
+ assert.equal(v.mediaRef,'media-id-123'); // course-builder ne touche pas au média, juste la FK
45
+ });
46
+ console.log(`\n✅ @mostajs/course-builder — ${pass} tests OK`);