mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-18 12:05:22 +02:00
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
151 lines
6.7 KiB
JavaScript
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.`);
|
|
}
|