Files
MangaCircus/test/discovery-anchors.test.js
T
MaashkinoozheandClaude Opus 5.5 cd6a30a76b Discovery final review fixes: lean publish, robust scoring, cheap prune (BL-0051)
- Cache prune counts directory entries first and only reads files when over
  the cap; tests for cross-kind prune, schema-mismatch and corrupt entries.
- Scoring tolerates malformed manga.json and cached records (non-list tags,
  object or null relations, non-object items); a tag without a rank no longer
  passes the top-tag filter; authors without an integer AniList id are dropped.
- discovery.json publishes only candidates some feed shows; examined/clipped
  still describe the pool. Tests that asserted the published count equals the
  pool size now check the pool via candidatePool() and the published set
  against the feeds.
- The post-harvest rescore failure test now breaks discovery.json right before
  the publish (via a scraper stand-in) instead of relying on tags: 5, and a
  following successful harvest clears the stale lastError.
- Consent text names everything sent to AniList; paused label mentions admin
  harvests; the UI test asserts the XSS seed instead of skipping it.
- Slot-mix test proves the fresh/outside reservations matter.
- Docs: privacy note, fresh fallback reason, per-period cap, dry-run wording,
  published-candidate rule; plan/spec hygiene and spec status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:43:31 +01:00

74 lines
4.3 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { anchorsFor, topTagsFor, ANCHOR_WEIGHTS, MAX_ANCHORS } = require('../discovery/anchors');
const user = (settings = { discoveryEnabled: true }) => ({ name: 'ann', settings });
const manga = (id, anilist, title = `Manga ${id}`) => ({ id, title, meta: anilist ? { 'x-anilist': { id: anilist } } : {} });
test('anchors are empty without opt-in', () => {
const library = [manga('a', 1)];
assert.deepEqual(anchorsFor({ user: user({}), db: {}, library, greatest: { entries: [] } }), []);
assert.deepEqual(anchorsFor({ user: user({ discoveryEnabled: false }), db: {}, library, greatest: { entries: [] } }), []);
});
test('weights, dedupe and cap', () => {
const greatest = { entries: [{ id: 'g1', title: 'Great One', metadata: { id: 7 } }, { id: 'g2', title: 'No Meta' }] };
const db = { greatestRatings: { ann: { g1: 'good', g2: 'flame' } } };
const anchors = anchorsFor({ user: user(), db, library: [manga('m7', 7, 'Owned Seven')], greatest });
assert.deepEqual(anchors, [{ anilistId: 7, weight: 3, source: 'good', title: 'Great One' }]);
assert.equal(ANCHOR_WEIGHTS.flame, 5);
const many = Array.from({ length: 20 }, (_, i) => manga(`m${i}`, i + 1));
const capped = anchorsFor({ user: user(), db: {}, library: many, greatest: { entries: [] } });
assert.equal(capped.length, MAX_ANCHORS);
assert.equal(capped.length, 15);
assert.deepEqual(capped.map(a => a.anilistId), Array.from({ length: 15 }, (_, i) => i + 1));
assert.equal(capped[0].source, 'owned');
});
test('reading anchors need the reading opt-in', () => {
const library = [manga('m1', 1), manga('m2', 2)];
const db = { progress: { ann: { m1: { c1: { done: true } }, m2: { c1: { done: false } } } } };
const off = anchorsFor({ user: user(), db, library, greatest: { entries: [] } });
assert.deepEqual(off.map(a => [a.anilistId, a.source]), [[1, 'owned'], [2, 'owned']]);
const on = anchorsFor({ user: user({ discoveryEnabled: true, discoveryReadingActivity: true }), db, library, greatest: { entries: [] } });
assert.deepEqual(on.map(a => [a.anilistId, a.source, a.weight, a.title]), [[1, 'read', 3, 'Manga m1'], [2, 'owned', 1, 'Manga m2']]);
});
test('feedback keys parse anilist ids and ignore others', () => {
const db = { discoveryFeedback: { ann: {
'anilist:10': 'interested', 'anilist:11': 'maybe', 'anilist:12': 'dismissed', 'anilist:13': 'hidden',
'greatest:x': 'interested', 'anilist:abc': 'interested' } } };
const anchors = anchorsFor({ user: user(), db, library: [], greatest: { entries: [] } });
assert.deepEqual(anchors, [
{ anilistId: 10, weight: 4, source: 'interested', title: null },
{ anilistId: 11, weight: 2, source: 'maybe', title: null }]);
});
test('top tags weigh anchors, skip low rank and adult tags', () => {
const records = {
1: { tags: [{ name: 'Action', rank: 100, kind: 'genre', isAdult: false }, { name: 'Ecchi', rank: 90, kind: 'tag', isAdult: true },
{ name: 'Faint', rank: 59, kind: 'tag', isAdult: false }] },
2: { tags: [{ name: 'Action', rank: 100, kind: 'genre', isAdult: false }, { name: 'Zeta', rank: 60, kind: 'tag', isAdult: false },
{ name: 'Alpha', rank: 80, kind: 'tag', isAdult: false }] }
};
const anchors = [{ anilistId: 1, weight: 5 }, { anilistId: 2, weight: 3 }, { anilistId: 3, weight: 5 }];
const recordOf = id => records[id] || null;
assert.deepEqual(topTagsFor(anchors, recordOf), [
{ name: 'Action', kind: 'genre' }, { name: 'Alpha', kind: 'tag' }, { name: 'Zeta', kind: 'tag' }]);
assert.deepEqual(topTagsFor(anchors, recordOf, { mature: true }), [
{ name: 'Action', kind: 'genre' }, { name: 'Ecchi', kind: 'tag' }, { name: 'Alpha', kind: 'tag' }]);
});
test('top tags drop tags without a rank and survive malformed records', () => {
const records = {
1: { tags: [{ name: 'NoRank', kind: 'tag', isAdult: false }, { name: 'NullRank', rank: null, kind: 'tag', isAdult: false },
null, 7, { name: 'Kept', rank: 60, kind: 'tag', isAdult: false }] },
2: { tags: 5 },
3: { tags: { name: 'Object', rank: 90 } },
};
const anchors = [{ anilistId: 1, weight: 5 }, { anilistId: 2, weight: 3 }, { anilistId: 3, weight: 3 }];
assert.deepEqual(topTagsFor(anchors, id => records[id]), [{ name: 'Kept', kind: 'tag' }]);
});