'use strict'; const test = require('node:test'), assert = require('node:assert/strict'); const { applyVisit, emptyRecord, chapterInsight, publicSummary } = require('../reading-analytics'); const { exposureTracker, stateFor, IDLE_MS } = require('../public/reading-clock-core'); const pages = n => Array.from({ length: n }, (_, i) => i); const config = { version: 'webtoon', pageCount: 20, eligible: pages(20), now: 10000 }; const visit = (extra = {}) => ({ session: 'reading-visit', seq: 1, elapsedMs: 10000, activeMs: 9000, seen: [], ...extra }); test('59% is a draft; 60% credits the entire visit and final duration keeps growing', () => { const r = emptyRecord(), cfg = { ...config, pageCount: 100, eligible: pages(100) }; applyVisit(r, visit({ seen: pages(59) }), cfg); assert.equal(r.totalActiveMs, 0); assert.equal(chapterInsight(r), null); applyVisit(r, visit({ seq: 2, elapsedMs: 12000, activeMs: 11000, seen: pages(60) }), cfg); assert.equal(r.totalActiveMs, 11000); assert.equal(r.completedPasses, 1); applyVisit(r, visit({ seq: 3, elapsedMs: 18000, activeMs: 16000, seen: pages(65), final: true }), cfg); assert.equal(r.totalActiveMs, 16000); assert.equal(r.firstCompleteMs, 16000); assert.equal(r.lastCompleteMs, 16000); assert.equal(r.currentPass.closed, true); }); test('an exit snapshot overtaking a pulse counts once and survives a JSON reload', () => { const r = emptyRecord(); applyVisit(r, visit({ seq: 3, seen: pages(12), final: true }), config); const restored = JSON.parse(JSON.stringify(r)); assert.equal(applyVisit(restored, visit({ seq: 1, activeMs: 0 }), config).duplicate, true); assert.equal(applyVisit(restored, visit({ seq: 3, seen: pages(12), final: true }), config).duplicate, true); applyVisit(restored, visit({ seq: 4, seen: pages(13), activeMs: 10000, elapsedMs: 11000 }), config); assert.equal(restored.totalActiveMs, 10000); assert.equal(restored.completedPasses, 1); }); test('delayed delivery does not truncate an honest minute to twelve seconds', () => { const r = emptyRecord(); applyVisit(r, visit({ elapsedMs: 65000, activeMs: 60000, inputMs: { touch: 60000 }, seen: pages(12) }), config); assert.equal(r.totalActiveMs, 60000); assert.equal(r.inputMs.touch, 60000); }); test('one-picture lookup does not borrow prior coverage or change saved statistics', () => { const r = emptyRecord(); applyVisit(r, visit({ seen: pages(12), final: true }), config); applyVisit(r, visit({ session: 'lookup-visit', elapsedMs: 60000, activeMs: 59000, seen: [15], final: true }), { ...config, now: 70000 }); assert.equal(r.totalActiveMs, 9000); assert.equal(r.completedPasses, 1); assert.equal(chapterInsight(r).updatedAt, 10000); }); test('late finals update their own visit without replacing a newer visit', () => { const r = emptyRecord(); applyVisit(r, visit({ seen: pages(12) }), config); applyVisit(r, visit({ session: 'new-visit', seen: pages(12), activeMs: 4000 }), { ...config, now: 20000 }); applyVisit(r, visit({ seq: 2, seen: pages(14), elapsedMs: 15000, activeMs: 13000, final: true }), { ...config, now: 21000 }); assert.equal(r.currentPass.visit, 'new-visit'); assert.equal(r.firstCompleteMs, 13000); assert.equal(r.lastCompleteMs, 4000); assert.equal(r.totalActiveMs, 17000); }); test('legacy time and source IDs are retained; covers/extras use the eligible denominator', () => { const r = { ...emptyRecord(), totalActiveMs: 5000, completedPasses: 1, firstCompleteMs: 5000, lastCompleteMs: 5000, updatedAt: 500 }; const cfg = { ...config, eligible: [1, 3, 5, 7, 9] }; applyVisit(r, visit({ seen: [0, 1, 3, 5, 19], activeMs: 2000 }), cfg); assert.equal(r.totalActiveMs, 7000); assert.equal(r.firstCompleteMs, 5000); assert.equal(publicSummary(r, 'webtoon', cfg.eligible).currentPass.requiredPages, 3); }); test('fast webtoon slices above five pages per second are credited without a ceiling', () => { const t = exposureTracker(); for (let i = 0; i < 10; i++) t.update([{ index: i, loaded: true }], i * 120, true); t.update([], 1200, true); assert.deepEqual([...t.seen], pages(10)); }); test('jumping over pages to dwell on one image never invents skipped coverage', () => { const t = exposureTracker(); t.update([{ index: 0, loaded: true }], 0, true); t.update([{ index: 19, loaded: true }], 50, true); t.update([{ index: 19, loaded: true }], 60000, true); assert.deepEqual([...t.seen], [19]); }); test('unloaded and hidden pages cannot qualify; mixed loading does not pause readable pages', () => { assert.equal(IDLE_MS, 30000, 'The user-selected inactivity allowance is 30 seconds'); const t = exposureTracker(); t.update([{ index: 0, loaded: false }], 0, true); t.update([{ index: 0, loaded: false }], 1000, true); assert.equal(t.seen.size, 0); const view = { pages: [{ index: 0, loaded: true }, { index: 1, loaded: false }] }; const s = { visible: true, focused: true, lastInputAt: 0, now: 5000, owned: true, view }; assert.equal(stateFor(s), 'reading'); assert.equal(stateFor({ ...s, visible: false }), 'hidden'); assert.equal(stateFor({ ...s, focused: false }), 'unfocused'); assert.equal(stateFor({ ...s, now: IDLE_MS }), 'idle'); assert.equal(stateFor({ ...s, now: IDLE_MS - 1 }), 'reading'); t.update(view.pages, 1000, false); t.update(view.pages, 100000, false); assert.equal(t.seen.size, 0); });