diff --git a/app.js b/app.js index 4f59d06..208b64e 100644 --- a/app.js +++ b/app.js @@ -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 = `
Прогресс${progress()}%
-
${state.completed.length} из ${allLessons.length} уроков
`; +
${done} из ${allLessons.length} уроков
`; } 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 `

Основной путь

10 модулей.
Иди по порядку.

Начни с первого незавершённого урока. В M01 решения и рабочая карта проверяются прямо внутри урока. Проверки и диагностика дополняют путь, но не создают второй курс.

-
${hours[0]}–${hours[1]} ч

ориентир по времени

${progress()}%

пройдено

${state.completed.length}/${allLessons.length}

уроков завершено

+
${hours[0]}–${hours[1]} ч

ориентир по времени

${progress()}%

пройдено

${completedCount()}/${allLessons.length}

уроков завершено

Как двигаться:1. Разбери кейс2. Примени технику3. Заполни рабочий инструмент4. Проверь перенос на проект
${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); const ready = lesson.learningLab ? labReady(lesson).ready : checked.length >= lesson.criteria.length; return `
@@ -518,6 +527,7 @@ const open = nav?.classList.toggle("open") || false; event.currentTarget.setAttribute("aria-expanded", String(open)); }); + window.addEventListener("hashchange", render); render(); })(); \ No newline at end of file diff --git a/tests/learning-lab-completion-integrity.test.js b/tests/learning-lab-completion-integrity.test.js new file mode 100644 index 0000000..b64aad5 --- /dev/null +++ b/tests/learning-lab-completion-integrity.test.js @@ -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, /0%<\/strong>/); + assert.match(main.innerHTML, /0\/1<\/strong>/); + assert.match(sidebarProgress.innerHTML, />0% { + const state = baseState({ + completed: ['lab-a'], + lab: { 'lab-a': { drillAnswers: { d1: 'ok' }, workbook: { field1: 'fact' } } }, + }); + const { main } = run('course', state); + assert.match(main.innerHTML, /100%<\/strong>/); + assert.match(main.innerHTML, /1\/1<\/strong>/); +});