The save sheet gets "+ Previous page" / "+ Next page" (right to left for rtl in Page/Book) for a picture that runs over several pages. Moments store a layout (row | column); pages the selected region does not touch are dropped. Crops, viewer and gallery draw both layouts. Dialogs now clean up directly instead of waiting for the "close" event, which a backgrounded page can deliver late (a closed sheet stayed in the page and M stopped working). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
9.3 KiB
JavaScript
150 lines
9.3 KiB
JavaScript
'use strict';
|
||
// BL-0055: Moments, a reader's saved pages or page regions with exactly one category.
|
||
const test = require('node:test'), assert = require('node:assert/strict');
|
||
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
||
const { spawn } = require('child_process');
|
||
const { Canvas } = require('../tools/png');
|
||
const moments = require('../moments');
|
||
const { trimPages, momentPageLabel } = require('../public/moments');
|
||
const { READER_KEYS, MOMENT_VIEWER_KEYS, keyAction } = require('../public/keys');
|
||
|
||
test('regions are clamped fractions; a whole-page rectangle means no region', () => {
|
||
assert.deepEqual(moments.cleanRegion({ x: 0.1, y: 0.2, w: 0.3, h: 0.4 }), { x: 0.1, y: 0.2, w: 0.3, h: 0.4 });
|
||
assert.deepEqual(moments.cleanRegion({ x: -1, y: 0.5, w: 3, h: 2 }), { x: 0, y: 0.5, w: 1, h: 0.5 });
|
||
assert.equal(moments.cleanRegion({ x: 0, y: 0, w: 1, h: 1 }), null);
|
||
assert.equal(moments.cleanRegion(null), null);
|
||
assert.throws(() => moments.cleanRegion({ x: 0.5, y: 0.5, w: 0.001, h: 0.5 }), /too small/);
|
||
assert.throws(() => moments.cleanRegion({ x: 'a', y: 0, w: 1, h: 1 }), /Invalid/);
|
||
});
|
||
|
||
test('exactly one known category is required; notes are trimmed and capped', () => {
|
||
for (const cat of ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels']) assert.equal(moments.cleanCategory(cat), cat);
|
||
assert.throws(() => moments.cleanCategory(undefined), /Pick one category/);
|
||
assert.throws(() => moments.cleanCategory('meh'), /Pick one category/);
|
||
assert.equal(moments.cleanNote(' so good \n'), 'so good');
|
||
assert.equal(moments.cleanNote('x'.repeat(500)).length, moments.NOTE_MAX);
|
||
});
|
||
|
||
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||
test('side by side: a region inside one page of a spread becomes that page alone', () => {
|
||
const left = { i: 5, w: 700, h: 1000 }, right = { i: 4, w: 700, h: 1000 };
|
||
const one = trimPages([left, right], { x: 0.6, y: 0.1, w: 0.2, h: 0.3 });
|
||
assert.deepEqual(one.pages, [right]);
|
||
assert.ok(near(one.region.x, 0.2) && near(one.region.w, 0.4) && one.region.y === 0.1);
|
||
assert.equal(trimPages([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 }).pages.length, 2);
|
||
});
|
||
|
||
test('stacked (Flow): a picture across the break keeps only the pages it touches', () => {
|
||
const pages = [0, 1, 2, 3].map(i => ({ i, w: 800, h: 400 })); // each page is half as tall as wide: 0.5 of the width
|
||
// The stack is 2 units tall; y 0.4–0.6 is the lower half of page 1 and the upper half of page 2.
|
||
const cut = trimPages(pages, { x: 0.1, y: 0.4, w: 0.8, h: 0.2 }, 'column');
|
||
assert.deepEqual(cut.pages.map(p => p.i), [1, 2]);
|
||
assert.ok(near(cut.region.y, 0.3) && near(cut.region.h, 0.4) && cut.region.x === 0.1);
|
||
assert.deepEqual(trimPages(pages, null, 'column').pages.length, 4);
|
||
assert.equal(momentPageLabel(cut.pages), 'p. 2–3');
|
||
});
|
||
|
||
test('every reader key is bound once and the M / I split holds', () => {
|
||
const keys = READER_KEYS.flatMap(entry => entry.keys);
|
||
assert.equal(new Set(keys).size, keys.length);
|
||
assert.equal(keyAction(READER_KEYS, 'm'), 'moment');
|
||
assert.equal(keyAction(READER_KEYS, 'i'), 'bars');
|
||
assert.equal(keyAction(READER_KEYS, 'Escape'), 'bars');
|
||
assert.equal(keyAction(READER_KEYS, '?'), 'help');
|
||
assert.equal(keyAction(MOMENT_VIEWER_KEYS, 'ArrowLeft'), 'step');
|
||
assert.ok(READER_KEYS.every(entry => entry.label && entry.text));
|
||
});
|
||
|
||
async function freePort() {
|
||
const listener = net.createServer();
|
||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
||
const port = listener.address().port;
|
||
await new Promise(resolve => listener.close(resolve));
|
||
return port;
|
||
}
|
||
|
||
test('moments are private, validated, editable and survive page renumbering as "moved"', { timeout: 30000 }, async () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-moments-'));
|
||
let child;
|
||
try {
|
||
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
||
const chapterDir = path.join(library, 'Moon Garden', 'v01', 'c001 Roots');
|
||
fs.mkdirSync(chapterDir, { recursive: true }); fs.mkdirSync(data);
|
||
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(chapterDir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
|
||
const port = await freePort(), output = [];
|
||
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
|
||
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
|
||
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
|
||
child.stdout.on('data', d => output.push(String(d)));
|
||
child.stderr.on('data', d => output.push(String(d)));
|
||
const base = `http://127.0.0.1:${port}`;
|
||
let ready = false;
|
||
for (let n = 0; n < 100 && !ready; n++) {
|
||
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
|
||
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
|
||
}
|
||
assert.ok(ready, output.join(''));
|
||
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
|
||
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) });
|
||
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
|
||
const session = r => r.headers.get('set-cookie').split(';')[0];
|
||
|
||
const alice = session(await post(null, '/api/signup', { name: 'alice', password: 'alice-password' }));
|
||
await post(alice, '/api/admin/user', { name: 'bob', password: 'bob-password' });
|
||
const bob = session(await post(null, '/api/login', { name: 'bob', password: 'bob-password' }));
|
||
await post(alice, '/api/admin/rescan', { full: true });
|
||
|
||
const manga = await get(alice, '/api/manga?m=' + encodeURIComponent('Moon Garden'));
|
||
const c = manga.chapters[0].id, v = manga.chapters[0].versions[0].id;
|
||
const base3 = { m: 'Moon Garden', c, v };
|
||
|
||
// A category is required: nothing is ever dumped into an uncategorised pile.
|
||
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [1] })).status, 400);
|
||
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [9], cat: 'hot' })).status, 400);
|
||
|
||
const created = await (await post(alice, '/api/moments', { ...base3, pages: [1], cat: 'awesome', region: { x: 0.1, y: 0.5, w: 0.5, h: 0.25 }, note: ' big fight ' })).json();
|
||
assert.equal(created.moment.cat, 'awesome');
|
||
assert.equal(created.moment.note, 'big fight');
|
||
assert.equal(created.moment.pages[0].i, 1);
|
||
assert.match(created.moment.pages[0].u, /^lib\/Moon%20Garden\/v01\/c001%20Roots\/002\.png$/);
|
||
const spread = (await (await post(alice, '/api/moments', { ...base3, pages: [2, 0], cat: 'twist' })).json()).moment;
|
||
assert.deepEqual(spread.pages.map(p => p.i), [2, 0]);
|
||
assert.equal(spread.layout, 'row');
|
||
// Flow: a picture stacked over three pages; more than four pages or an unknown layout is refused.
|
||
const stacked = (await (await post(alice, '/api/moments', { ...base3, pages: [0, 1, 2], layout: 'column', cat: 'awesome', region: { x: 0, y: 0.2, w: 1, h: 0.5 } })).json()).moment;
|
||
assert.equal(stacked.layout, 'column');
|
||
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [0, 1, 2, 0], cat: 'hot' })).status, 400);
|
||
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [0], layout: 'grid', cat: 'hot' })).status, 400);
|
||
assert.equal((await post(alice, '/api/moments/delete', { id: stacked.id })).status, 200);
|
||
|
||
// Story order: page 0/2 spread starts before page 1.
|
||
const listed = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;
|
||
assert.deepEqual(listed.map(x => x.id), [spread.id, created.moment.id]);
|
||
const chapter = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(c)}`);
|
||
assert.deepEqual(chapter.moments.map(x => x.pages).sort(), [[1], [2, 0]].sort());
|
||
|
||
// Private: bob sees nothing and cannot touch alice's moments.
|
||
assert.deepEqual((await get(bob, '/api/moments?m=Moon%20Garden')).moments, []);
|
||
assert.equal((await get(bob, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(c)}`)).moments.length, 0);
|
||
assert.equal((await post(bob, '/api/moments/update', { id: created.moment.id, cat: 'hot' })).status, 404);
|
||
assert.equal((await post(bob, '/api/moments/delete', { id: created.moment.id })).status, 404);
|
||
|
||
const edited = (await (await post(alice, '/api/moments/update', { id: created.moment.id, cat: 'feels', region: null })).json()).moment;
|
||
assert.equal(edited.cat, 'feels'); assert.equal(edited.region, null);
|
||
assert.equal((await post(alice, '/api/moments/update', { id: created.moment.id, cat: '' })).status, 400);
|
||
|
||
// The file of page 2 goes away: the moment shows as moved instead of pointing at another picture.
|
||
fs.renameSync(path.join(chapterDir, '002.png'), path.join(chapterDir, '009.png'));
|
||
await post(alice, '/api/admin/rescan', { full: true });
|
||
const after = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;
|
||
assert.equal(after.find(x => x.id === created.moment.id).missing, true);
|
||
assert.equal(after.find(x => x.id === spread.id).missing, false);
|
||
|
||
assert.equal((await post(alice, '/api/moments/delete', { id: spread.id })).status, 200);
|
||
assert.equal((await get(alice, '/api/moments?m=Moon%20Garden')).moments.length, 1);
|
||
} finally {
|
||
if (child) child.kill();
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|