Files
chimera-Public/test/server/ch-1103-trophy-progression-planner.test.js

100 lines
5.3 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
createMemoryTrophyProgressionPlanRepository,
createTrophyProgressionPlanner,
} = require('../../src/services/trophy-progression-planner-service');
const NOW = Date.parse('2026-08-11T12:00:00.000Z');
const catalog = Object.freeze({
immutable: true,
catalogKey: 'np:11111111111111111111111111111111',
revision: 'fixture-1',
canonicalSha256: 'a'.repeat(64),
game: { title: 'Progression Fixture', releaseDate: '2020-01-10T00:00:00.000Z' },
trophies: [
{ id: 0, groupId: 0, grade: 'bronze', name: 'First Steps', description: 'Complete the tutorial.', earned: false, earnedDateTime: null },
{ id: 1, groupId: 0, grade: 'silver', name: 'Chapter Complete', description: 'Complete chapter five.', earned: false, earnedDateTime: null },
{ id: 2, groupId: 0, grade: 'gold', name: 'Collector', description: 'Collect all relics.', earned: false, earnedDateTime: null },
{ id: 3, groupId: 0, grade: 'platinum', name: 'Platinum', description: 'Earn every trophy.', earned: false, earnedDateTime: null },
],
});
function planner(value = catalog) {
return createTrophyProgressionPlanner({
repository: createMemoryTrophyProgressionPlanRepository(),
catalogProvider: { resolve: async input => input.catalogKey === value.catalogKey ? value : null },
now: () => NOW,
});
}
const baseInput = {
catalogKey: catalog.catalogKey,
mode: 'synthetic_unlock',
trophyIds: [3, 2, 0, 1],
startDateTime: '2021-02-01T18:00:00.000Z',
endDateTime: '2021-02-08T22:00:00.000Z',
minimumIntervalMinutes: 30,
strategy: 'balanced',
};
test('creates a deterministic import-optional synthetic timeline with platinum last', async () => {
const service = planner();
const first = await service.create(baseInput);
const second = await service.create(baseInput);
assert.equal(first.id, second.id);
assert.equal(first.provenance.psnImportRequired, false);
assert.equal(first.synthetic, true);
assert.equal(first.liveApply.available, false);
assert.deepEqual(first.items.map(item => item.trophyId), [0, 1, 2, 3]);
assert.equal(first.items.at(-1).grade, 'platinum');
assert.ok(first.items.every(item => item.timestampKind === 'synthetic-estimate'));
const times = first.items.map(item => Date.parse(item.proposedEarnedDateTime));
assert.ok(times.every((time, index) => index === 0 || time > times[index - 1]));
assert.ok(times[0] >= Date.parse(baseInput.startDateTime));
assert.ok(times.at(-1) <= Date.parse(baseInput.endDateTime));
assert.ok(times.at(-1) <= NOW);
});
test('rejects arbitrary trophy IDs, future windows and pre-release history', async () => {
const service = planner();
await assert.rejects(service.create({ ...baseInput, trophyIds: [99] }), error => error.code === 'TROPHY_PROGRESSION_SELECTION');
await assert.rejects(service.create({ ...baseInput, endDateTime: '2026-08-12T00:00:00.000Z' }), error => error.code === 'TROPHY_PROGRESSION_FUTURE');
await assert.rejects(service.create({ ...baseInput, startDateTime: '2019-01-01T00:00:00.000Z' }), error => error.code === 'TROPHY_PROGRESSION_RELEASE');
});
test('timestamp edit mode freezes original earned values and changes only explicit selections', async () => {
const earnedCatalog = Object.freeze({ ...catalog, canonicalSha256: 'b'.repeat(64), trophies: catalog.trophies.map((item, index) => Object.freeze({
...item, earned: index < 3, earnedDateTime: index < 3 ? `2021-02-0${index + 2}T20:00:00.000Z` : null,
})) });
const service = planner(earnedCatalog);
const plan = await service.create({ ...baseInput, mode: 'timestamp_edit', trophyIds: [1], endDateTime: '2021-02-06T22:00:00.000Z' });
assert.equal(plan.items.length, 1);
assert.equal(plan.items[0].trophyId, 1);
assert.equal(plan.items[0].originalEarnedDateTime, '2021-02-03T20:00:00.000Z');
assert.notEqual(plan.items[0].proposedEarnedDateTime, plan.items[0].originalEarnedDateTime);
assert.equal(earnedCatalog.trophies[1].earnedDateTime, '2021-02-03T20:00:00.000Z');
assert.match(plan.confirmationPhrase, /^EDIT LOCAL TROPHY TIMES 1 /);
});
test('keeps unselected earned trophies as hard anchors and rejects contradictory anchors', async () => {
const anchored = { ...catalog, canonicalSha256: 'c'.repeat(64), trophies: catalog.trophies.map(item => ({ ...item })) };
anchored.trophies[1].earned = true;
anchored.trophies[1].earnedDateTime = '2021-02-04T20:00:00.000Z';
const plan = await planner(anchored).create({ ...baseInput, trophyIds: [0, 2, 3] });
const byId = new Map(plan.items.map(item => [item.trophyId, Date.parse(item.proposedEarnedDateTime)]));
assert.ok(byId.get(0) < Date.parse(anchored.trophies[1].earnedDateTime));
assert.ok(byId.get(2) > Date.parse(anchored.trophies[1].earnedDateTime));
anchored.canonicalSha256 = 'd'.repeat(64);
anchored.trophies[0].earned = true;
anchored.trophies[0].earnedDateTime = '2021-02-05T20:00:00.000Z';
await assert.rejects(planner(anchored).create({ ...baseInput, trophyIds: [2, 3] }), error => error.code === 'TROPHY_PROGRESSION_ANCHORS');
});
test('refuses timestamp edits without an exact original local timestamp', async () => {
await assert.rejects(planner().create({ ...baseInput, mode: 'timestamp_edit', trophyIds: [0] }), error => error.code === 'TROPHY_PROGRESSION_ORIGINAL');
});