Files
Tiago Valente 08c9abb573 CheatSpecter 0.4.1
A quality pass after 0.4.0 was tried on a real console.

- Fix the Learn page crashing in current browsers when changing lesson steps
- Clear feedback for every search outcome; real progress with time left and Cancel
- Dialogs scroll inside the screen with Save and Cancel always in view
- Fix a console error on every closed connection to the payload
- Copy buttons work when the page is served from the PS5
- The practice game sits beside the lesson's tools and lasts between steps
- Damaged cheat files, vanishing error messages, unreadable memory shown as zeros
- Phone layout: one-line header, larger switches, readable rows
- Browser tests under test/ui and a guard against effects that return values
2026-10-10 15:19:56 -07:00

151 lines
6.7 KiB
JavaScript

// Checks every lesson in learn/ the way the app checks a download, plus the
// things only the whole folder can show: that the index and the files agree.
//
// node scripts/check-lessons.mjs [folder]
//
// Prints what is wrong and exits with 1, or prints a summary and exits with 0.
// The tests run the same check (test/learn.test.mjs).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { checkIndex, checkLesson } from '../src/lib/learn.ts';
import { PracticeGame } from '../src/lib/practice/game.ts';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_DIR = path.join(ROOT, 'learn');
// ---- what the words say ------------------------------------------------------------
//
// The format check cannot tell whether a lesson is true. These catch the ways
// lesson text has gone wrong before: naming a button that the app calls
// something else, saying where on the screen the practice game is (it is at the
// bottom of a phone and beside the lesson on a wide screen), and wording the
// project does not use.
/** Spellings and subjects the lessons stay away from, each with what to write instead. */
const WORDING = [
[/\b(colour|behaviour|favour|cancelled|cancelling|grey|centre|labelled|untick|learnt|whilst|afterwards|towards)/i, 'use US spelling'],
[/\b(online|multiplayer|multi-player|single-player|singleplayer)\b/i, 'the lessons do not go into online or single-player play; the standing note is "use at your own risk"'],
[/\b(at|on) the (bottom|top|left|right|side) of the (window|screen|page)\b/i, 'do not say where on the screen something is; the layout differs between a phone and a wide window. Name the thing instead ("the practice game\'s panel")'],
];
/** Bold words in practice steps that are examples to type, not names of things on screen. */
const EXAMPLES = ['Lots of gold', 'padlock'];
/** Everything the app can put on screen, as one long string: its source. */
function appText() {
let all = '';
for (const dir of ['src/ui', 'src/lib']) {
const full = path.join(ROOT, dir);
for (const file of fs.readdirSync(full)) if (/\.tsx?$/.test(file)) all += fs.readFileSync(path.join(full, file), 'utf8') + '\n';
}
return all.replace(/\s+/g, ' ');
}
/** Every piece of text a lesson shows, with where it is. */
function textsOf(lesson) {
const out = [
['title', lesson.title],
['summary', lesson.summary],
];
lesson.steps.forEach((s, i) => {
const at = `steps[${i}]`;
for (const key of ['title', 'text', 'question', 'explain', 'done']) if (typeof s[key] === 'string') out.push([`${at}.${key}`, s[key]]);
for (const key of ['options', 'hints']) (s[key] ?? []).forEach((t, j) => out.push([`${at}.${key}[${j}]`, t]));
if (s.goal) for (const g of s.goal.check === 'all' ? s.goal.of : [s.goal]) out.push([`${at}.goal.label`, g.label]);
});
return out;
}
/**
* @param {import('../src/lib/learn.ts').Lesson} lesson
* @param {string} app
* @returns {string[]}
*/
function wordProblems(lesson, app) {
const problems = [];
for (const [where, text] of textsOf(lesson)) {
for (const [pattern, instead] of WORDING) {
const found = pattern.exec(text);
if (found) problems.push(`${where}: "${found[0]}": ${instead}`);
}
}
// In a practice step, bold is for what the learner presses or looks at. It has to be called that in the app or in the game.
const games = JSON.stringify(lesson.games);
lesson.steps.forEach((s, i) => {
if (s.kind !== 'practice') return;
for (const text of [s.text, s.done, ...s.hints]) {
for (const [, name] of text.matchAll(/\*\*([^*]+)\*\*/g)) {
if (/^\d/.test(name) || EXAMPLES.includes(name)) continue;
if (app.includes(name) || games.includes(JSON.stringify(name).slice(0, -1))) continue;
problems.push(`steps[${i}]: "${name}" is in bold like a button or a label, but nothing in the app or in the game is called that. Use the app's own words, or add it to EXAMPLES in scripts/check-lessons.mjs if it is something to type`);
}
}
});
return problems;
}
/**
* @param {string} [dir] the learn/ folder
* @returns {{ problems: string[], lessons: import('../src/lib/learn.ts').Lesson[], index: import('../src/lib/learn.ts').LessonIndex | null }}
*/
export function checkLessons(dir = DEFAULT_DIR) {
const problems = [];
const lessons = [];
const read = (file) => JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
let index = null;
try {
index = checkIndex(read('index.json'));
} catch (e) {
problems.push(`index.json: ${e.message}`);
return { problems, lessons, index };
}
const listed = new Set();
const app = appText();
for (const course of index.courses) {
for (const ref of course.lessons) {
const file = `lessons/${ref.id}.json`;
listed.add(`${ref.id}.json`);
let lesson;
try {
lesson = checkLesson(read(file));
} catch (e) {
problems.push(`${file}: ${e.code === 'ENOENT' ? 'is listed in index.json but the file is missing' : e.message}`);
continue;
}
lessons.push(lesson);
for (const p of wordProblems(lesson, app)) problems.push(`${file}: ${p}`);
if (lesson.id !== ref.id) problems.push(`${file}: its id is "${lesson.id}", which should match its file name`);
// The list of lessons is drawn from the index alone, so it has to say the same as the lesson.
for (const key of ['title', 'summary', 'minutes']) {
if (lesson[key] !== ref[key]) problems.push(`${file}: "${key}" differs from index.json`);
}
const practice = lesson.steps.some((s) => s.kind === 'practice');
if (practice !== ref.practice) problems.push(`${file}: index.json says "practice": ${ref.practice}, but the lesson ${practice ? 'has' : 'has no'} practice steps`);
for (const [name, def] of Object.entries(lesson.games)) {
try {
new PracticeGame(def, 'CSPR00000');
} catch (e) {
problems.push(`${file}: the game "${name}" cannot be built: ${e.message}`);
}
if (!lesson.steps.some((s) => s.kind === 'practice' && s.game === name)) problems.push(`${file}: the game "${name}" is not used by any step`);
}
}
}
for (const file of fs.readdirSync(path.join(dir, 'lessons'))) {
if (!listed.has(file)) problems.push(`lessons/${file}: is not listed in index.json`);
}
return { problems, lessons, index };
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
const { problems, lessons } = checkLessons(process.argv[2]);
if (problems.length) {
for (const p of problems) console.error(p);
process.exit(1);
}
const steps = lessons.reduce((n, l) => n + l.steps.length, 0);
console.log(`${lessons.length} lessons, ${steps} steps: all fine.`);
}