- 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>
373 lines
21 KiB
JavaScript
373 lines
21 KiB
JavaScript
'use strict';
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { generate, generateAsync, buildCandidates, candidatePool, feedForUser, mixSlots, mergeUserFeed, MAX_CANDIDATES, SLOTS } = require('../discovery/engine');
|
|
const { DiscoveryStore } = require('../discovery/store');
|
|
const { DiscoveryCache } = require('../discovery/cache');
|
|
|
|
test('discovery is bounded, diversified, opt-in, evidence-linked and user-specific', () => {
|
|
const { loadCatalog } = require('../greatest');
|
|
const greatest = loadCatalog();
|
|
const users = { alice: { name: 'alice', settings: { discoveryEnabled: true } },
|
|
bob: { name: 'bob', settings: { discoveryEnabled: true } },
|
|
off: { name: 'off', settings: {} } };
|
|
const db = { users, greatestRatings: { alice: { [greatest.entries[0].id]: 'flame' },
|
|
bob: { [greatest.entries[20].id]: 'flame' } }, discoveryFeedback: { alice: {} }, progress: {} };
|
|
const cacheDir = path.join(os.tmpdir(), 'nonexistent-discovery-cache');
|
|
const result = generate({ db, library: [], greatest, cacheDir });
|
|
// Only candidates a feed shows are published; the pool itself stays bounded at 2000.
|
|
const shown = new Set(Object.values(result.feeds).flatMap(feed => (feed.items || []).map(item => item.candidateId)));
|
|
assert.deepEqual(Object.keys(result.candidates).sort(), [...shown].sort());
|
|
const pool = candidatePool({ greatest, library: [], cacheDir });
|
|
assert.equal(pool.candidates.length, Math.min(2000, result.examined));
|
|
assert.ok(result.feeds.alice.items.length > 0 && result.feeds.alice.items.length <= 20);
|
|
assert.ok(result.feeds.bob.items.length > 0);
|
|
assert.equal(result.feeds.off.disabled, true);
|
|
assert.ok(new Set(pool.candidates.map(c => c.period)).size >= 10, 'An early catalog page must not crowd out later eras');
|
|
assert.ok(result.feeds.alice.items.every(item => item.evidenceIds.every(id => item.evidence.some(e => e.id === id))));
|
|
assert.ok(result.feeds.alice.items.some(item => item.evidence.some(e => e.kind === 'personal')));
|
|
assert.ok(!result.feeds.bob.items.some(item => item.evidence.some(e => e.label?.includes(greatest.entries[0].title) && e.kind === 'personal')));
|
|
assert.notDeepEqual(result.feeds.alice.items.map(x => x.candidateId), result.feeds.bob.items.map(x => x.candidateId));
|
|
const first = result.feeds.alice.items[0].candidateId;
|
|
db.discoveryFeedback.alice[first] = 'dismissed';
|
|
const rerun = generate({ db, library: [], greatest, cacheDir: path.join(os.tmpdir(), 'nonexistent-discovery-cache') });
|
|
assert.ok(!rerun.feeds.alice.items.some(item => item.candidateId === first));
|
|
assert.equal(rerun.feeds.bob.items.length, result.feeds.bob.items.length);
|
|
});
|
|
|
|
test('discovery store publishes atomically and refuses to overwrite corrupt data', t => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-store-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
const store = new DiscoveryStore(dir);
|
|
assert.equal(store.load().revision, 0);
|
|
const value = store.publish({ candidates: {}, feeds: {}, startedAt: new Date().toISOString(), examined: 0, clipped: 0 });
|
|
assert.equal(value.revision, 1);
|
|
store.publish({ candidates: {}, feeds: {}, startedAt: new Date().toISOString(), examined: 0, clipped: 0 });
|
|
assert.equal(store.load().revision, 2);
|
|
assert.ok(fs.existsSync(store.file + '.prev'));
|
|
fs.writeFileSync(store.file, '{broken');
|
|
assert.throws(() => store.publish({ candidates: {}, feeds: {} }), /refusing to overwrite/);
|
|
assert.equal(fs.readFileSync(store.file, 'utf8'), '{broken');
|
|
});
|
|
|
|
function tempStore(t) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-meta-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
return new DiscoveryStore(dir);
|
|
}
|
|
const emptyRun = () => ({ candidates: {}, feeds: {}, startedAt: new Date().toISOString(), examined: 0, clipped: 0 });
|
|
|
|
test('store loads a Phase A file without new fields', t => {
|
|
const store = tempStore(t);
|
|
fs.writeFileSync(store.file, JSON.stringify({ schema: 1, revision: 3, candidates: {}, feeds: {}, lastRun: { status: 'success' } }));
|
|
const data = store.load();
|
|
assert.equal(data.revision, 3);
|
|
assert.equal(data.harvest, null);
|
|
assert.equal(data.settings, null);
|
|
assert.deepEqual(data.nightly, { lastDate: null });
|
|
});
|
|
|
|
test('patch keeps revision and publish keeps harvest', t => {
|
|
const store = tempStore(t);
|
|
store.publish(emptyRun());
|
|
store.patch({ harvest: { status: 'success' } });
|
|
assert.equal(store.load().revision, 1);
|
|
store.patch({ settings: { a: 1 } });
|
|
store.patch({ nightly: { lastDate: '2026-10-25' } });
|
|
store.publish(emptyRun());
|
|
const data = store.load();
|
|
assert.equal(data.harvest.status, 'success');
|
|
assert.deepEqual(data.settings, { a: 1 });
|
|
assert.equal(data.nightly.lastDate, '2026-10-25');
|
|
assert.equal(data.revision, 2);
|
|
});
|
|
|
|
test('patch refuses a corrupt store', t => {
|
|
const store = tempStore(t);
|
|
fs.writeFileSync(store.file, '{broken');
|
|
assert.throws(() => store.patch({ harvest: {} }), /refusing to overwrite/);
|
|
assert.equal(fs.readFileSync(store.file, 'utf8'), '{broken');
|
|
});
|
|
|
|
// ---- Wider pool (BL-0051): AniList seeds, new score parts, slot mix ----
|
|
const NOW = () => new Date('2026-10-25T12:00:00Z');
|
|
const FETCHED_AT = '2026-10-20T00:00:00.000Z';
|
|
const NO_SCRAPER_CACHE = path.join(os.tmpdir(), 'nonexistent-discovery-cache');
|
|
const AUTHOR = { staffId: 7, name: 'Author Seven', role: 'Story' };
|
|
function seedRecord(id, extra = {}) {
|
|
return { anilistId: id, title: `Seed ${id}`, altTitles: [], format: 'MANGA', isAdult: false, status: 'completed',
|
|
year: 2010, averageScore: 70, popularity: 1000, trending: 0,
|
|
tags: [{ name: 'Action', rank: 90, kind: 'genre', isAdult: false }],
|
|
authors: [{ staffId: 900 + id, name: `Writer ${id}`, role: 'Story' }],
|
|
coverUrl: `https://img.example/${id}.jpg`, siteUrl: `https://anilist.co/manga/${id}`, ...extra };
|
|
}
|
|
const seed = (id, extra) => ({ anilistId: id, record: seedRecord(id, extra) });
|
|
function harvestFixture(t) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-harvest-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
const cache = new DiscoveryCache(dir, { now: () => Date.parse(FETCHED_AT) });
|
|
cache.put('title', 100, seedRecord(100, { title: 'Anchor One', authors: [AUTHOR],
|
|
tags: [{ name: 'Drama', rank: 100, kind: 'genre', isAdult: false }, { name: 'Time Skip', rank: 80, kind: 'tag', isAdult: false }] }));
|
|
cache.put('recs', 100, [
|
|
{ ...seed(201), rating: 42 },
|
|
{ ...seed(209, { authors: [AUTHOR] }), rating: 10 },
|
|
{ ...seed(207, { isAdult: true }), rating: 5 },
|
|
{ ...seed(208, { tags: [{ name: 'Nudity', rank: 65, kind: 'tag', isAdult: true }] }), rating: 5 },
|
|
{ ...seed(212), rating: 3 },
|
|
]);
|
|
cache.put('creator', 7, [{ ...seed(202, { authors: [AUTHOR] }), role: 'Story' }, { ...seed(209, { authors: [AUTHOR] }), role: 'Story' }]);
|
|
cache.put('tagTop', 'genre:Drama', [seed(203, { averageScore: 80, tags: [{ name: 'Drama', rank: 90, kind: 'genre', isAdult: false }] })]);
|
|
cache.put('tagTop', 'tag:Time Skip', [seed(204, { averageScore: 70 })]);
|
|
cache.put('fresh', 'global', [seed(205, { status: 'ongoing', year: 2025 }), seed(206, { status: 'ongoing', year: 2020 }),
|
|
seed(210, { title: 'Owned Title', year: 2020 }), seed(211, { title: 'Queued Title' }), seed(213, { status: 'ongoing', year: 2026 })]);
|
|
return cache;
|
|
}
|
|
const fixtureGreatest = { entries: [
|
|
{ id: 'g1', title: 'Anchor One', period: '2000s', rank: 1, note: '', metadata: { id: 100 } },
|
|
{ id: 'g2', title: 'Voted Two', period: '1990s', rank: 2, note: '', metadata: { id: 212 } },
|
|
] };
|
|
function fixtureWorld(t, settings = {}) {
|
|
const user = { name: 'alice', settings: { discoveryEnabled: true, ...settings } };
|
|
const db = { users: { alice: user }, greatestRatings: { alice: { g1: 'flame', g2: 'good' } },
|
|
discoveryFeedback: { alice: { 'anilist:213': 'dismissed' } }, progress: {} };
|
|
const library = [{ id: 'm1', title: 'Owned Title', meta: { year: 2020, 'x-anilist': { id: 210 } } }];
|
|
return { user, db, library, greatest: fixtureGreatest, harvestCache: harvestFixture(t), queuedTitles: ['Queued Title'] };
|
|
}
|
|
function fixtureFeed(t, settings) {
|
|
const world = fixtureWorld(t, settings);
|
|
const candidates = buildCandidates({ greatest: world.greatest, library: world.library, cacheDir: NO_SCRAPER_CACHE, harvestCache: world.harvestCache });
|
|
const feed = feedForUser({ ...world, candidates, now: NOW });
|
|
return { world, candidates, feed, item: id => feed.items.find(item => item.candidateId === `anilist:${id}`) };
|
|
}
|
|
|
|
test('engine: empty harvest cache equals Phase A pool', t => {
|
|
const { loadCatalog } = require('../greatest');
|
|
const greatest = loadCatalog();
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-empty-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
const db = { users: {}, greatestRatings: {}, discoveryFeedback: {}, progress: {} };
|
|
const without = candidatePool({ greatest, library: [], cacheDir: NO_SCRAPER_CACHE });
|
|
const withEmpty = candidatePool({ greatest, library: [], cacheDir: NO_SCRAPER_CACHE, harvestCache: new DiscoveryCache(dir) });
|
|
assert.equal(MAX_CANDIDATES, 2000);
|
|
assert.deepEqual(withEmpty.candidates.map(c => c.id).sort(), without.candidates.map(c => c.id).sort());
|
|
const users = { alice: { name: 'alice', settings: { discoveryEnabled: true } } };
|
|
const run = harvestCache => generate({ db: { ...db, users }, library: [], greatest, cacheDir: NO_SCRAPER_CACHE, harvestCache });
|
|
assert.deepEqual(Object.keys(run(new DiscoveryCache(dir)).candidates).sort(), Object.keys(run(null).candidates).sort());
|
|
});
|
|
|
|
test('published candidates are only those referenced by a feed', async t => {
|
|
const world = fixtureWorld(t);
|
|
world.db.users.bob = { name: 'bob', settings: { discoveryEnabled: true } };
|
|
world.db.users.off = { name: 'off', settings: {} };
|
|
const args = { db: world.db, library: world.library, greatest: world.greatest, cacheDir: NO_SCRAPER_CACHE,
|
|
harvestCache: world.harvestCache, queuedTitles: world.queuedTitles, now: NOW };
|
|
const referenced = result => new Set(Object.values(result.feeds).flatMap(feed => (feed.items || []).map(item => item.candidateId)));
|
|
for (const result of [generate(args), await generateAsync(args), await generateAsync({ ...args, onlyUser: 'alice' })]) {
|
|
const shown = referenced(result);
|
|
assert.ok(shown.size > 0);
|
|
for (const id of Object.keys(result.candidates)) assert.ok(shown.has(id), `${id} is published but not shown`);
|
|
for (const id of shown) assert.ok(result.candidates[id], `${id} is shown but not published`);
|
|
assert.ok(result.examined > Object.keys(result.candidates).length, 'examined still counts the whole pool');
|
|
assert.equal(result.clipped, 0);
|
|
}
|
|
});
|
|
|
|
test('malformed library metadata and cache records score without throwing', t => {
|
|
const world = fixtureWorld(t, { discoveryReadingActivity: true });
|
|
world.library.push(
|
|
{ id: 'm2', title: 'Bad Tags', meta: { tags: 5, 'x-anilist': { id: 220, relations: {} } } },
|
|
{ id: 'm3', title: 'Bad Relations', meta: { tags: [7, null, { name: 'Drama' }, 'Drama'],
|
|
'x-anilist': { id: 221, relations: [null, 3, 'x', { id: 222, format: 'MANGA', type: 'SEQUEL', title: 'Sequel Title' }] } } },
|
|
{ id: 'm4', title: 'Bad Identity', meta: { tags: { a: 1 }, 'x-anilist': 5 } });
|
|
world.db.progress = { alice: { m2: { c1: { done: true } }, m3: { c1: { done: true } } } };
|
|
world.harvestCache.put('title', 220, seedRecord(220, { authors: [null, 5, AUTHOR], tags: [null, 'Drama', { name: 'Drama', kind: 'genre' }] }));
|
|
world.harvestCache.put('recs', 220, [null, 5, { ...seed(230), rating: 7 }]);
|
|
const args = { db: world.db, library: world.library, greatest: world.greatest, cacheDir: NO_SCRAPER_CACHE,
|
|
harvestCache: world.harvestCache, queuedTitles: world.queuedTitles, now: NOW };
|
|
const result = generate(args);
|
|
const ids = result.feeds.alice.items.map(item => item.candidateId);
|
|
assert.ok(ids.includes('anilist:222'), JSON.stringify(ids));
|
|
assert.ok(ids.includes('anilist:230'), JSON.stringify(ids));
|
|
});
|
|
|
|
test('harvested records join the pool and fill Greatest candidates', t => {
|
|
const { candidates } = fixtureFeed(t);
|
|
const byId = new Map(candidates.map(c => [c.id, c]));
|
|
for (const id of [201, 202, 203, 204, 205, 206, 207, 208, 209]) assert.ok(byId.has(`anilist:${id}`), `anilist:${id}`);
|
|
const fromRecs = byId.get('anilist:201');
|
|
assert.equal(fromRecs.isAdult, false);
|
|
assert.equal(fromRecs.averageScore, 70);
|
|
assert.deepEqual(fromRecs.tags, ['Action']);
|
|
assert.deepEqual(fromRecs.authors, ['Writer 201']);
|
|
assert.equal(fromRecs.tagRecords[0].rank, 90);
|
|
assert.equal(fromRecs.authorRecords[0].staffId, 1101);
|
|
assert.equal(fromRecs.fetchedAt, FETCHED_AT);
|
|
const greatestOne = byId.get('anilist:212');
|
|
assert.equal(greatestOne.greatestId, 'g2');
|
|
assert.equal(greatestOne.title, 'Voted Two');
|
|
assert.equal(greatestOne.isAdult, false);
|
|
assert.equal(greatestOne.tagRecords.length, 1);
|
|
});
|
|
|
|
test('community rec, creator, tagTop and fresh parts and reasons', t => {
|
|
const { item } = fixtureFeed(t);
|
|
const rec = item(201);
|
|
assert.equal(rec.scoreParts.communityRec, 3.71);
|
|
assert.equal(rec.group, 'recs');
|
|
assert.equal(rec.reason, 'AniList readers who liked Anchor One recommend this (42).');
|
|
const recEvidence = rec.evidence.find(e => e.id === 'rec:100:anilist:201');
|
|
assert.equal(recEvidence.kind, 'recommendation');
|
|
assert.equal(recEvidence.fetchedAt, FETCHED_AT);
|
|
assert.equal(recEvidence.url, 'https://anilist.co/manga/201');
|
|
assert.ok(rec.evidenceIds.includes('rec:100:anilist:201'));
|
|
|
|
const creator = item(202);
|
|
assert.equal(creator.scoreParts.sameCreator, 4);
|
|
assert.equal(creator.group, 'creator');
|
|
assert.equal(creator.reason, 'Also by Author Seven — you liked Anchor One.');
|
|
const creatorEvidence = creator.evidence.find(e => e.id === 'creator:7:anilist:202');
|
|
assert.equal(creatorEvidence.kind, 'creator');
|
|
assert.equal(creatorEvidence.fetchedAt, FETCHED_AT);
|
|
|
|
const top = item(203);
|
|
assert.equal(top.scoreParts.tagTop, 3);
|
|
assert.equal(top.group, 'tagTop');
|
|
assert.equal(top.reason, 'Top-rated in Drama, one of your most-liked tags.');
|
|
assert.equal(top.evidence.find(e => e.id === 'tagtop:genre:Drama:anilist:203').kind, 'tag-top');
|
|
assert.equal(item(204).scoreParts.tagTop, 0, 'averageScore below 75 earns no tagTop part');
|
|
|
|
const fresh = item(205);
|
|
assert.equal(fresh.scoreParts.fresh, 2);
|
|
assert.equal(fresh.group, 'fresh');
|
|
assert.equal(fresh.reason, 'New: started 2025, ongoing and trending.');
|
|
const freshEvidence = fresh.evidence.find(e => e.id === 'fresh:anilist:205');
|
|
assert.equal(freshEvidence.kind, 'fresh');
|
|
assert.equal(freshEvidence.fetchedAt, FETCHED_AT);
|
|
assert.equal(item(206).scoreParts.fresh, 1);
|
|
for (const row of [rec, creator, top, fresh]) assert.ok(row.reason.length <= 280);
|
|
});
|
|
|
|
test('isAdult filter', t => {
|
|
const plain = fixtureFeed(t);
|
|
assert.equal(plain.item(207), undefined);
|
|
assert.equal(plain.item(208), undefined, 'an adult tag with rank >= 60 counts as mature');
|
|
const mature = fixtureFeed(t, { discoveryMature: true });
|
|
assert.ok(mature.item(207));
|
|
assert.ok(mature.item(208));
|
|
});
|
|
|
|
test('multiple seeds merge reasons', t => {
|
|
const { item } = fixtureFeed(t);
|
|
const both = item(209);
|
|
assert.equal(both.scoreParts.sameCreator, 4);
|
|
assert.equal(both.scoreParts.communityRec, 2.73);
|
|
assert.equal(both.score, 6.73);
|
|
assert.equal(both.group, 'creator');
|
|
assert.deepEqual(both.reasons, ['Also by Author Seven — you liked Anchor One.',
|
|
'AniList readers who liked Anchor One recommend this (10).']);
|
|
assert.equal(both.reason, both.reasons[0]);
|
|
assert.ok(both.evidenceIds.includes('rec:100:anilist:209'));
|
|
assert.ok(both.evidenceIds.includes('creator:7:anilist:209'));
|
|
});
|
|
|
|
test('owned, queued, voted, dismissed still excluded', t => {
|
|
const { item, candidates } = fixtureFeed(t);
|
|
for (const id of [210, 211, 212, 213]) {
|
|
assert.ok(candidates.some(c => c.id === `anilist:${id}`), `anilist:${id} is in the pool`);
|
|
assert.equal(item(id), undefined, `anilist:${id} must not be suggested`);
|
|
}
|
|
});
|
|
|
|
test('slot mix', () => {
|
|
const row = (id, score, group, extra = {}) => ({ candidateId: `anilist:${id}`, score, group, tags: ['Drama'],
|
|
scoreParts: { fresh: 0 }, ...extra });
|
|
const rows = [];
|
|
for (let i = 0; i < 30; i++) rows.push(row(1000 + i, 30 - i, 'recs'));
|
|
rows.push(row(2001, 0.5, 'fresh', { scoreParts: { fresh: 1 } }), row(2002, 0.4, 'fresh', { scoreParts: { fresh: 1 } }));
|
|
rows.push(row(3001, 0.2, '1990s', { tags: ['Sports'] }), row(3002, 0.1, '1990s', { tags: [] }));
|
|
const picked = mixSlots(rows, { topTags: [{ name: 'Drama', kind: 'genre' }] });
|
|
assert.equal(SLOTS.perKind, 8);
|
|
assert.equal(picked.filter(r => r.group === 'recs').length, 8);
|
|
assert.ok(picked.some(r => r.candidateId === 'anilist:2001') && picked.some(r => r.candidateId === 'anilist:2002'));
|
|
assert.ok(picked.some(r => r.candidateId === 'anilist:3001') && picked.some(r => r.candidateId === 'anilist:3002'));
|
|
assert.equal(picked.length, 12, 'never pads up to 20');
|
|
assert.deepEqual(picked.map(r => r.rank), picked.map((_, i) => i + 1));
|
|
for (let i = 1; i < picked.length; i++) assert.ok(picked[i - 1].score >= picked[i].score);
|
|
|
|
const many = [];
|
|
for (let i = 0; i < 40; i++) many.push(row(4000 + i, 100 - i, ['recs', 'creator', 'tagTop', 'fresh', '1980s'][i % 5],
|
|
{ scoreParts: { fresh: i % 5 === 3 ? 1 : 0 } }));
|
|
const full = mixSlots(many, { topTags: [] });
|
|
assert.equal(full.length, 20);
|
|
assert.ok(full.filter(r => r.group === '1980s').length <= SLOTS.perPeriod);
|
|
});
|
|
|
|
test('slot mix reservations let low-scored fresh and outside rows past a full feed', () => {
|
|
const row = (id, score, group, extra = {}) => ({ candidateId: `anilist:${id}`, score, group, tags: ['Drama'],
|
|
scoreParts: { fresh: 0 }, ...extra });
|
|
// 32 higher-scored rows that the caps alone would allow: 8 per seed kind, 4 per period.
|
|
const rows = [];
|
|
const groups = ['recs', 'creator', 'tagTop', '1980s', '1990s'];
|
|
const caps = { recs: 8, creator: 8, tagTop: 8, '1980s': 4, '1990s': 4 };
|
|
let id = 5000;
|
|
for (const group of groups) for (let i = 0; i < caps[group]; i++) rows.push(row(id++, 100 - rows.length, group));
|
|
assert.ok(rows.length > 20);
|
|
rows.push(row(2001, 1, 'fresh', { scoreParts: { fresh: 1 } }), row(2002, 0.9, 'fresh', { scoreParts: { fresh: 1 } }));
|
|
rows.push(row(3001, 0.2, '2000s', { tags: ['Sports'] }), row(3002, 0.1, '2000s', { tags: [] }));
|
|
const picked = mixSlots(rows, { topTags: [{ name: 'Drama', kind: 'genre' }] });
|
|
assert.equal(picked.length, 20);
|
|
for (const low of ['anilist:2001', 'anilist:2002', 'anilist:3001', 'anilist:3002'])
|
|
assert.ok(picked.some(r => r.candidateId === low), `${low} needs its reserved slot`);
|
|
for (const group of groups) assert.ok(picked.filter(r => r.group === group).length <= caps[group], group);
|
|
});
|
|
|
|
test('mergeUserFeed keeps other users\' referenced candidates', () => {
|
|
const prior = { candidates: { a: { id: 'a' }, b: { id: 'b' }, c: { id: 'c' } },
|
|
feeds: { alice: { items: [{ candidateId: 'a' }] }, bob: { items: [{ candidateId: 'b' }] }, off: { items: [], disabled: true } } };
|
|
const result = { candidates: { d: { id: 'd' }, b: { id: 'b', fresh: true } }, feeds: { alice: { items: [{ candidateId: 'd' }] } } };
|
|
const merged = mergeUserFeed(prior, result, 'alice');
|
|
assert.deepEqual(Object.keys(merged.feeds).sort(), ['alice', 'bob', 'off']);
|
|
assert.deepEqual(merged.feeds.alice.items, [{ candidateId: 'd' }]);
|
|
assert.deepEqual(merged.feeds.bob, prior.feeds.bob);
|
|
assert.deepEqual(Object.keys(merged.candidates).sort(), ['b', 'd']);
|
|
assert.equal(merged.candidates.b.fresh, true);
|
|
});
|
|
|
|
test('generateAsync matches generate and can score one user', async t => {
|
|
const world = fixtureWorld(t);
|
|
world.db.users.bob = { name: 'bob', settings: { discoveryEnabled: true } };
|
|
const args = { db: world.db, library: world.library, greatest: world.greatest, cacheDir: NO_SCRAPER_CACHE,
|
|
harvestCache: world.harvestCache, queuedTitles: world.queuedTitles, now: NOW };
|
|
const sync = generate(args);
|
|
const all = await generateAsync(args);
|
|
assert.deepEqual(all.feeds.alice.items, sync.feeds.alice.items);
|
|
assert.deepEqual(Object.keys(all.candidates), Object.keys(sync.candidates));
|
|
const one = await generateAsync({ ...args, onlyUser: 'alice' });
|
|
assert.deepEqual(Object.keys(one.feeds), ['alice']);
|
|
assert.deepEqual(one.feeds.alice.items, sync.feeds.alice.items);
|
|
});
|
|
|
|
test('scoring one user stays under 200 ms on 2000 candidates', t => {
|
|
const world = fixtureWorld(t);
|
|
const tagNames = ['Drama', 'Action', 'Romance', 'Time Skip', 'Comedy', 'Horror'];
|
|
const candidates = [];
|
|
for (let i = 0; i < 2000; i++) {
|
|
const id = 10000 + i;
|
|
candidates.push({ id: `anilist:${id}`, title: `Synthetic ${id}`, year: 2000 + (i % 27), type: 'MANGA',
|
|
status: i % 3 ? 'completed' : 'ongoing', cover: null, metadataUrl: `https://anilist.co/manga/${id}`,
|
|
tags: [tagNames[i % 6], tagNames[(i + 1) % 6]], authors: [`Writer ${i % 50}`], period: null, editorialRank: null,
|
|
greatestId: null, evidence: [], relations: [], isAdult: i % 97 === 0, averageScore: 60 + (i % 40),
|
|
tagRecords: [{ name: tagNames[i % 6], rank: 80, kind: 'tag', isAdult: false }],
|
|
authorRecords: [{ staffId: i % 50, name: `Writer ${i % 50}`, role: 'Story' }], fetchedAt: FETCHED_AT, seed: 'recs' });
|
|
}
|
|
feedForUser({ ...world, candidates, now: NOW });
|
|
const started = performance.now();
|
|
const feed = feedForUser({ ...world, candidates, now: NOW });
|
|
const elapsed = performance.now() - started;
|
|
assert.ok(feed.items.length > 0);
|
|
assert.ok(elapsed < 200, `took ${elapsed.toFixed(1)} ms`);
|
|
});
|