Files
MangaCircus/test/reading-analytics.test.js

103 lines
4.9 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
applyPulse, chapterInsight, compactRanges, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('../reading-analytics');
const pulse = (session, seq, extra = {}) => ({
session, seq, activeMs: 0, loadingMs: 0,
inputMs: { mouse: 0, touch: 0, keyboard: 0 }, seen: [],
fastBursts: 0, rejectedPageViews: 0, maxPps: 0,
...extra,
});
test('page ranges remain compact and deterministic', () => {
assert.deepEqual(compactRanges([8, 2, 3, 3, 4, 9, 12]), [[2, 4], [8, 9], [12, 12]]);
});
test('reading pulses are idempotent and server-clamped', () => {
const record = emptyRecord(), options = { version: 'source-a', pageCount: 4, eligible: [0, 1, 2, 3], now: 10000 };
applyPulse(record, pulse('session-one', 1, { activeMs: 9800, inputMs: { touch: 9800 } }), options);
assert.equal(record.totalActiveMs, 9800);
assert.equal(record.sessions, 1);
assert.equal(record.inputMs.touch, 9800);
const duplicate = applyPulse(record, pulse('session-one', 1, { activeMs: 9800 }), { ...options, now: 20000 });
assert.equal(duplicate.duplicate, true);
assert.equal(record.totalActiveMs, 9800);
applyPulse(record, pulse('session-one', 2, { activeMs: 50000, loadingMs: 50000, inputMs: { mouse: 50000 } }), { ...options, now: 21000 });
assert.equal(record.totalActiveMs + record.loadingMs, 9800 + 12000);
assert.ok(record.inputMs.mouse <= 6000);
});
test('eligible coverage completes a pass and a reread starts a fresh pass', () => {
const version = { pages: [{ t: ['cover'] }, { t: [] }, { t: ['extra'] }, { t: [] }] };
const eligible = eligiblePages(version, { skipCovers: true, skipExtras: true });
assert.deepEqual(eligible, [1, 3]);
const record = emptyRecord();
applyPulse(record, pulse('first-pass', 1, { activeMs: 1000, inputMs: { keyboard: 1000 }, seen: [0, 1, 2, 3] }), {
version: 'source-a', pageCount: 4, eligible, now: 1000,
});
assert.equal(record.completedPasses, 1);
assert.equal(record.firstCompleteMs, 1000);
assert.deepEqual(record.currentPass.seenRanges, [[1, 1], [3, 3]]);
applyPulse(record, pulse('second-pass', 1), { version: 'source-a', pageCount: 4, eligible, now: 2000 });
const summary = publicSummary(record, 'source-a', eligible);
assert.equal(summary.currentPass.complete, false);
assert.equal(summary.currentPass.seenPages, 0);
assert.equal(summary.totalActiveMs, 1000);
});
test('switching source versions starts separate page coverage without losing lifetime time', () => {
const record = emptyRecord();
applyPulse(record, pulse('version-test', 1, { activeMs: 800, seen: [0] }), {
version: 'source-a', pageCount: 2, eligible: [0, 1], now: 1000,
});
applyPulse(record, pulse('version-test', 2, { activeMs: 500, seen: [2] }), {
version: 'source-b', pageCount: 3, eligible: [0, 1, 2], now: 2000,
});
const summary = publicSummary(record, 'source-b', [0, 1, 2]);
assert.equal(summary.totalActiveMs, 1300);
assert.deepEqual(summary.currentPass.seenRanges, [[2, 2]]);
});
test('chapter and manga insights distinguish untimed, partial, complete and reread records', () => {
assert.equal(chapterInsight(null), null);
const records = {
first: { ...emptyRecord(), totalActiveMs: 60000, updatedAt: 100, currentPass: { activeMs: 60000 } },
second: { ...emptyRecord(), totalActiveMs: 540000, lastCompleteMs: 180000, firstCompleteMs: 210000, completedPasses: 2, updatedAt: 300, currentPass: { activeMs: 180000, completedAt: 300 } },
third: { ...emptyRecord(), totalActiveMs: 300000, lastCompleteMs: 300000, firstCompleteMs: 300000, completedPasses: 1, updatedAt: 200, currentPass: { activeMs: 300000, completedAt: 200 } },
};
assert.deepEqual(chapterInsight(records.first), {
totalActiveMs: 60000, firstCompleteMs: null, lastCompleteMs: null, completedPasses: 0,
currentPassMs: 60000, currentPassComplete: false, updatedAt: 100,
});
assert.deepEqual(mangaInsight(records, ['first', 'second', 'third', 'missing']), {
activeMs: 900000,
timedChapters: 3,
completedChapters: 2,
completedPasses: 3,
rereads: 1,
typicalChapterMs: 240000,
lastChapterId: 'second',
updatedAt: 300,
});
});
test('library insights select the most-read and most-recent manga', () => {
const summary = libraryInsight([
{ id: 'a', reading: { activeMs: 1000, timedChapters: 1, completedPasses: 1, rereads: 0, updatedAt: 100, lastChapterId: '1/1' } },
{ id: 'b', reading: { activeMs: 3000, timedChapters: 2, completedPasses: 4, rereads: 2, updatedAt: 90, lastChapterId: '1/2' } },
{ id: 'empty', reading: { activeMs: 0, timedChapters: 0, completedPasses: 0, rereads: 0, updatedAt: 0 } },
]);
assert.deepEqual(summary, {
activeMs: 4000, timedChapters: 3, completedPasses: 5, rereads: 2, timedMangas: 2,
mostTimeMangaId: 'b', lastMangaId: 'a', lastChapterId: '1/1', updatedAt: 100,
});
});