Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,27 @@
.replaceAll("'", "'");
}

function isLessonComplete(lesson) {
if (!lesson || !state.completed.includes(lesson.id)) return false;
return lesson.learningLab ? labReady(lesson).ready : true;
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply evidence-valid completion to the validation gate

For legacy learners whose stored completed array contains both M01 lesson IDs but whose new lab evidence is absent, this helper correctly makes the course treat those lessons as incomplete, while m01-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 👍 / 👎.

}

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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh completion UI when required evidence is removed

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 done remains this render-time snapshot. The lesson header consequently continues to say completed and the primary action remains a link to the next lesson while the status says required evidence is missing; rerender or update the completion-dependent controls when lab readiness changes.

Useful? React with 👍 / 👎.

const ready = lesson.learningLab ? labReady(lesson).ready : checked.length >= lesson.criteria.length;

return `<div class="page lesson-layout">
Expand Down Expand Up @@ -518,6 +527,7 @@
const open = nav?.classList.toggle("open") || false;
event.currentTarget.setAttribute("aria-expanded", String(open));
});

window.addEventListener("hashchange", render);
render();
})();
107 changes: 107 additions & 0 deletions tests/learning-lab-completion-integrity.test.js
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>/);
});