Add deployment reading windows and progress-aware quiz unlock

This commit is contained in:
Codex committed 2026-10-14 15:34:55 +02:00
1 parent edbc9c6056
commit d8eba9169f
15 files changed
+471 -5

No files matched your search

+58 -1
View File
@@ -19,6 +19,11 @@ if (peerGatewayQa) {
new PeeringState(peerData).generateIdentity('ComposeQA');
}
const pausedConfirmQa = process.argv.includes('--paused-confirm');
const readingGateQa = process.argv.includes('--reading-limit-ui');
if (readingGateQa) fs.writeFileSync(path.join(data, 'reading-quiz.json'), JSON.stringify({ version: 1, manga: {
'Moon Garden': [0, 1, 2].map(i => ({ id: `moon-${i}`, prompt: `Moon Garden question ${i}?`, answers: [`moonanswer${i}`], requires: ['1/1'], evidence: 'Disposable Compose fixture' })),
'Before the Rain': [0, 1, 2].map(i => ({ id: `rain-${i}`, prompt: `Before the Rain question ${i}?`, answers: [`rainanswer${i}`], requires: ['1/1'], evidence: 'Disposable Compose fixture' })),
} }));
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true,
sourcePaused: pausedConfirmQa ? { weebcentral: true } : {},
queue: pausedConfirmQa ? [{ id: 'ready-fixture', source: 'weebcentral',
@@ -44,6 +49,8 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
}
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), SENTINEL_DATA_PATH: path.join(data, 'sentinel'),
SCRAPER_TOKEN_PATH: tokenFile, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' } : {}),
...(peerGatewayQa ? { PEER_DATA_PATH: path.join(data, 'peering'), PEER_USAGE_PATH: path.join(data, 'peering-usage'), PEER_PORT: process.env.QA_PEER_PORT || '8193' } : {}) };
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])];
const docker = (...command) => new Promise((resolve, reject) => {
@@ -77,6 +84,54 @@ let keep = false;
let inventory;
await until(async () => { inventory = await (await api('/api/library')).json(); return inventory.mangas?.length === 3; }, 'Fixture inventory missing');
const byTitle = title => inventory.mangas.find(m => m.title === title).id;
if (readingGateQa) {
const manga = byTitle('Moon Garden'), other = byTitle('Before the Rain');
const chapter = (await (await api('/api/manga?m=' + encodeURIComponent(manga))).json()).chapters[0].id;
const chapterUrl = '/api/chapter?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter);
const pageUrl = '/lib/' + encodeURIComponent(manga) + '/v01/c0001%20The%20Beginning/0001.png';
assert.equal((await api(chapterUrl)).status, 423, 'chapter JSON must be gated');
assert.equal((await api(pageUrl)).status, 423, 'chapter image must be gated');
assert.equal((await api('/lib/' + encodeURIComponent(manga) + '/cover.png')).status, 200);
assert.equal((await (await api('/api/reading-access?m=' + encodeURIComponent(manga))).json()).quizAvailable, false);
for (const title of [manga, other]) {
const id = (await (await api('/api/manga?m=' + encodeURIComponent(title))).json()).chapters[0].id;
assert.equal((await api('/api/progress', { m: title, c: id, p: 0, n: 1, done: true })).status, 200);
}
assert.equal((await (await api('/api/reading-access?m=' + encodeURIComponent(manga))).json()).quizAvailable, true);
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const context = await browser.newContext();
const [name, ...values] = cookie.split('=');
await context.addCookies([{ name, value: values.join('='), url }]);
const page = await context.newPage();
await page.goto(url + '/read.html?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter));
await page.getByRole('heading', { name: 'Reading is closed right now' }).waitFor();
await page.getByRole('button', { name: 'Start five-question challenge' }).click();
await page.getByText('Question 1 of 5').waitFor();
await page.goto(url + '/manga.html?m=' + encodeURIComponent(manga));
await page.goto(url + '/read.html?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter));
await page.getByRole('button', { name: 'Start five-question challenge' }).waitFor();
} finally { await browser.close(); }
if (!process.argv.includes('--keep')) {
let question = await (await api('/api/reading-quiz/start', {})).json();
const wrong = await api('/api/reading-quiz/answer', { challenge: question.challenge, answer: 'incorrect' });
assert.equal((await wrong.json()).correct, false);
for (let i = 0; i < 5; i++) {
const match = /(?:Moon Garden|Before the Rain) question (\d)/.exec(question.prompt);
const answer = (question.mangaTitle === 'Moon Garden' ? 'moonanswer' : 'rainanswer') + match[1];
question = await (await api('/api/reading-quiz/answer', { challenge: question.challenge, answer })).json();
}
assert.equal(question.complete, true);
assert.equal((await api(chapterUrl)).status, 200);
const image = await api(pageUrl);
assert.equal(image.status, 200);
assert.equal(image.headers.get('cache-control'), 'private, no-store');
console.log('PASS: schedule blocks chapter and page bytes, progress-gated mixed-manga quiz unlocks reading');
} else {
console.log('PASS: isolated playable gate stays locked with progress-qualified sample questions');
}
}
const catalog = { revision: 0, collections: [{ id: 'moon-garden', title: 'Moon Garden Universe', coverMode: 'mosaic', members: [
{ mangaId: byTitle('Moon Garden'), role: 'main', readingOrder: 1 }, { mangaId: byTitle('Before the Rain'), role: 'prequel', readingOrder: 2 }] }],
relations: [{ from: byTitle('Moon Garden'), to: byTitle('Before the Rain'), type: 'prequel', state: 'confirmed' }] };
@@ -159,7 +214,9 @@ let keep = false;
keep = process.argv.includes('--keep');
if (keep) {
const envFile = path.join(dir, 'qa.env');
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE'].map(k => `${k}=${env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
}
} finally {
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const { loadQuestionBank } = require('../reading-access');
const file = process.argv[2];
if (!file) { console.error('Usage: node tools/validate-reading-quiz.js <catalog.json>'); process.exit(2); }
try {
const bank = loadQuestionBank(path.resolve(file));
console.log(JSON.stringify({ works: Object.keys(bank).length,
questions: Object.values(bank).reduce((count, questions) => count + questions.length, 0) }));
} catch (error) { console.error(error.message); process.exitCode = 1; }