100 lines
5.3 KiB
JavaScript
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');
|
|
});
|