-
Notifications
You must be signed in to change notification settings - Fork 0
fix: keep Learning Lab completion evidence-valid #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: baseline/m01-learning-lab-2026-09-07
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,17 +32,27 @@ | |
| .replaceAll("'", "'"); | ||
| } | ||
|
|
||
| function isLessonComplete(lesson) { | ||
| if (!lesson || !state.completed.includes(lesson.id)) return false; | ||
| return lesson.learningLab ? labReady(lesson).ready : true; | ||
| } | ||
|
|
||
| function completedCount() { | ||
| return allLessons.filter((lesson) => isLessonComplete(lesson)).length; | ||
| } | ||
|
|
||
| function progress() { | ||
| return allLessons.length ? Math.round((state.completed.length / allLessons.length) * 100) : 0; | ||
| return allLessons.length ? Math.round((completedCount() / allLessons.length) * 100) : 0; | ||
| } | ||
|
|
||
| function renderSidebarProgress() { | ||
| const target = document.querySelector("#sidebar-progress"); | ||
| if (!target) return; | ||
| const done = completedCount(); | ||
| target.innerHTML = ` | ||
| <div class="label-row"><span>Прогресс</span><strong>${progress()}%</strong></div> | ||
| <div class="progress-track" aria-label="Пройдено ${progress()}%"><div class="progress-fill" style="width:${progress()}%"></div></div> | ||
| <div class="label-row" style="margin-top:9px"><span>${state.completed.length} из ${allLessons.length} уроков</span></div>`; | ||
| <div class="label-row" style="margin-top:9px"><span>${done} из ${allLessons.length} уроков</span></div>`; | ||
| } | ||
|
|
||
| function setActiveNav(route) { | ||
|
|
@@ -63,20 +73,19 @@ | |
| } | ||
|
|
||
| function moduleCompletion(module) { | ||
| const done = module.lessons.filter((lesson) => state.completed.includes(lesson.id)).length; | ||
| const done = module.lessons.filter((lesson) => isLessonComplete(lesson)).length; | ||
| const total = module.lessons.length; | ||
| return { done, total, percent: total ? Math.round((done / total) * 100) : 0 }; | ||
| } | ||
|
|
||
| function nextLesson() { | ||
| if (state.lastLesson && !state.completed.includes(state.lastLesson)) { | ||
| return allLessons.find((lesson) => lesson.id === state.lastLesson) || allLessons[0]; | ||
| } | ||
| return allLessons.find((lesson) => !state.completed.includes(lesson.id)) || allLessons.at(-1); | ||
| const lastLesson = state.lastLesson ? allLessons.find((lesson) => lesson.id === state.lastLesson) : null; | ||
| if (lastLesson && !isLessonComplete(lastLesson)) return lastLesson; | ||
| return allLessons.find((lesson) => !isLessonComplete(lesson)) || allLessons.at(-1); | ||
| } | ||
|
|
||
| function moduleTargetLesson(module) { | ||
| return module.lessons.find((lesson) => !state.completed.includes(lesson.id)) || module.lessons.at(-1); | ||
| return module.lessons.find((lesson) => !isLessonComplete(lesson)) || module.lessons.at(-1); | ||
| } | ||
|
|
||
| function ensureLabState(id) { | ||
|
|
@@ -170,7 +179,7 @@ | |
| return `<div class="page"> | ||
| <section class="course-intro"> | ||
| <div><p class="eyebrow">Основной путь</p><h1>10 модулей.<br>Иди по порядку.</h1><p class="lead">Начни с первого незавершённого урока. В M01 решения и рабочая карта проверяются прямо внутри урока. Проверки и диагностика дополняют путь, но не создают второй курс.</p></div> | ||
| <div class="course-metrics"><strong>${hours[0]}–${hours[1]} ч</strong><p>ориентир по времени</p><strong>${progress()}%</strong><p>пройдено</p><strong>${state.completed.length}/${allLessons.length}</strong><p>уроков завершено</p></div> | ||
| <div class="course-metrics"><strong>${hours[0]}–${hours[1]} ч</strong><p>ориентир по времени</p><strong>${progress()}%</strong><p>пройдено</p><strong>${completedCount()}/${allLessons.length}</strong><p>уроков завершено</p></div> | ||
| </section> | ||
| <div class="path-note"><strong>Как двигаться:</strong><span>1. Разбери кейс</span><span>2. Примени технику</span><span>3. Заполни рабочий инструмент</span><span>4. Проверь перенос на проект</span></div> | ||
| <div class="module-list">${DATA.modules.map((module, index) => { | ||
|
|
@@ -294,7 +303,7 @@ | |
| const previous = allLessons[index - 1]; | ||
| const next = allLessons[index + 1]; | ||
| const checked = state.criteria[id] || []; | ||
| const done = state.completed.includes(id); | ||
| const done = isLessonComplete(lesson); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a completed Learning Lab is open and the learner clears a required workbook field, the input handler saves the invalid evidence and the sidebar immediately drops the completion, but Useful? React with 👍 / 👎. |
||
| const ready = lesson.learningLab ? labReady(lesson).ready : checked.length >= lesson.criteria.length; | ||
|
|
||
| return `<div class="page lesson-layout"> | ||
|
|
@@ -518,6 +527,7 @@ | |
| const open = nav?.classList.toggle("open") || false; | ||
| event.currentTarget.setAttribute("aria-expanded", String(open)); | ||
| }); | ||
|
|
||
| window.addEventListener("hashchange", render); | ||
| render(); | ||
| })(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| const test = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const fs = require('node:fs'); | ||
| const path = require('node:path'); | ||
| const vm = require('node:vm'); | ||
|
|
||
| const appCode = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); | ||
|
|
||
| function storageFrom(initial = {}) { | ||
| const values = new Map(Object.entries(initial)); | ||
| return { | ||
| getItem(key) { return values.has(key) ? values.get(key) : null; }, | ||
| setItem(key, value) { values.set(key, String(value)); }, | ||
| }; | ||
| } | ||
|
|
||
| function classListStub() { | ||
| return { add() {}, remove() {}, toggle() { return false; } }; | ||
| } | ||
|
|
||
| function run(route, state) { | ||
| const main = { innerHTML: '', focus() {} }; | ||
| const sidebarProgress = { innerHTML: '' }; | ||
| const mobileNav = { classList: classListStub() }; | ||
| const menuButton = { addEventListener() {}, setAttribute() {} }; | ||
| const toast = { textContent: '', classList: classListStub() }; | ||
|
|
||
| const document = { | ||
| querySelector(selector) { | ||
| if (selector === '#main') return main; | ||
| if (selector === '#sidebar-progress') return sidebarProgress; | ||
| if (selector === '#mobile-nav') return mobileNav; | ||
| if (selector === '#menu-button') return menuButton; | ||
| if (selector === '#toast') return toast; | ||
| return null; | ||
| }, | ||
| querySelectorAll() { return []; }, | ||
| }; | ||
|
|
||
| const localStorage = storageFrom({ 'pm01-state-v1': JSON.stringify(state) }); | ||
| const lesson = { | ||
| id: 'lab-a', | ||
| title: 'Lab A', | ||
| thesis: 'Тезис', | ||
| minutes: 15, | ||
| body: [], | ||
| model: '', | ||
| practice: [], | ||
| criteria: [], | ||
| learningLab: { | ||
| skill: 'Проверять решение через обязательный кейс и рабочий инструмент.', | ||
| technique: { name: 'Technique', purpose: 'Purpose', steps: ['Step'], model: 'MODEL' }, | ||
| workedExample: { title: 'Example', steps: ['Example step'] }, | ||
| drills: [{ | ||
| id: 'd1', stage: 'cold', required: true, title: 'Case', situation: 'Situation', prompt: 'Decision?', | ||
| options: [{ id: 'ok', label: 'Option', feedback: 'Feedback', score: 3 }], | ||
| }], | ||
| workbookTitle: 'Workbook', | ||
| workbookFields: [{ id: 'field1', label: 'Evidence', prompt: 'Add evidence', required: true }], | ||
| transferPrompt: 'Apply to a real project.', | ||
| }, | ||
| }; | ||
|
|
||
| const window = { | ||
| PM01: { | ||
| flows: [], diagnostics: [], tools: [], | ||
| modules: [{ id: 'm01', title: 'M01', duration: '1 ч', outcome: 'Outcome', lessons: [lesson] }], | ||
| }, | ||
| addEventListener() {}, scrollTo() {}, | ||
| }; | ||
| const location = { hash: `#/${route}` }; | ||
| const context = { window, document, localStorage, location, console, Blob, URL, setTimeout() { return 1; }, clearTimeout() {} }; | ||
| vm.createContext(context); | ||
| vm.runInContext(appCode, context, { filename: 'app.js' }); | ||
| return { main, sidebarProgress }; | ||
| } | ||
|
|
||
| function baseState(overrides = {}) { | ||
| return { | ||
| completed: [], notes: {}, criteria: {}, lastLesson: null, diagnostic: {}, lab: {}, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| test('legacy completed flag does not bypass current Learning Lab evidence', () => { | ||
| const { main } = run('lesson/lab-a', baseState({ completed: ['lab-a'] })); | ||
| assert.match(main.innerHTML, /0\/1 решений · 0\/1 полей/); | ||
| assert.match(main.innerHTML, /id="complete-lesson"[^>]*disabled/); | ||
| assert.doesNotMatch(main.innerHTML, /Вернуться к программе →/); | ||
| }); | ||
|
|
||
| test('course progress excludes a stale Learning Lab completion without current evidence', () => { | ||
| const { main, sidebarProgress } = run('course', baseState({ completed: ['lab-a'] })); | ||
| assert.match(main.innerHTML, /<strong>0%<\/strong>/); | ||
| assert.match(main.innerHTML, /<strong>0\/1<\/strong>/); | ||
| assert.match(sidebarProgress.innerHTML, />0%</); | ||
| }); | ||
|
|
||
| test('Learning Lab completion remains valid when current required evidence is present', () => { | ||
| const state = baseState({ | ||
| completed: ['lab-a'], | ||
| lab: { 'lab-a': { drillAnswers: { d1: 'ok' }, workbook: { field1: 'fact' } } }, | ||
| }); | ||
| const { main } = run('course', state); | ||
| assert.match(main.innerHTML, /<strong>100%<\/strong>/); | ||
| assert.match(main.innerHTML, /<strong>1\/1<\/strong>/); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For legacy learners whose stored
completedarray contains both M01 lesson IDs but whose new lab evidence is absent, this helper correctly makes the course treat those lessons as incomplete, whilem01-validation-app.js:isStudied()still checks only the raw flags. After answering its two separate drills,validationView()therefore unlocks the post-case and labels the learner studied despite neither Learning Lab being complete, allowing stale flags to contaminate the experiment this change is intended to protect.Useful? React with 👍 / 👎.