Files
core2026/app/game-frontend/test/dashboardReadModel.test.ts
T
Hide_D 19629fe3cc fix: 갱신 비용에 따라 접속 점수를 기록
revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
2026-08-17 11:10:38 +00:00

253 lines
8.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common';
import {
createMergedReadModelRefreshQueue,
resolveDashboardContextBundleInclude,
resolveDashboardRefreshPlan,
} from '../src/utils/dashboardReadModel.ts';
void test('last-turn-time-only events do not schedule any dashboard query', () => {
const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), {
generalId: 7,
cityId: 3,
nationId: 2,
});
assert.deepEqual(plan, {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
});
void test('selects only the read models affected by the current identity', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
generalIds: [7, 99],
reservedGeneralIds: [7],
recordGeneralIds: [7],
};
assert.deepEqual(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }), {
context: true,
lobby: false,
map: false,
commands: true,
contacts: false,
boardAccess: true,
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
});
});
void test('refreshes the map only for map-projection changes', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
generalIds: [7],
mapGeneralIds: [7],
};
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
});
void test('routes defence, tax-rate, and current-city-state events to their exact dashboard slices', () => {
const identity = { generalId: 7, cityId: 3, nationId: 2 };
const defence = resolveDashboardRefreshPlan(
{
...createEmptyRealtimeReadModelChanges(),
cityIds: [3],
mapCityIds: [],
},
identity
);
assert.deepEqual(defence, {
context: true,
lobby: false,
map: false,
commands: true,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const taxRate = resolveDashboardRefreshPlan(
{
...createEmptyRealtimeReadModelChanges(),
nationIds: [2],
mapNationIds: [],
frontStatusNationIds: [],
},
identity
);
assert.deepEqual(taxRate, {
context: true,
lobby: false,
map: false,
commands: true,
contacts: false,
boardAccess: true,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const cityState = resolveDashboardRefreshPlan(
{
...createEmptyRealtimeReadModelChanges(),
cityIds: [3],
mapCityIds: [3],
},
identity
);
assert.equal(cityState.context, true);
assert.equal(cityState.commands, true);
assert.equal(cityState.map, true);
});
void test('keeps conservative map behavior for rolling-deploy payloads without projections', () => {
const changes = {
generalIds: [7],
cityIds: [],
nationIds: [],
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
};
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
});
void test('does not refresh front status for contact-only permission changes', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
generalIds: [9],
contactsChanged: true,
frontStatusGeneralIds: [],
};
const plan = resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 });
assert.equal(plan.contacts, true);
assert.equal(plan.lobby, false);
assert.equal(plan.frontStatus, false);
});
void test('refreshes only front status for a global survey projection change', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
frontStatusChanged: true,
};
assert.deepEqual(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }), {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
});
});
void test('targets a submitted survey projection to its own general', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
frontStatusActorIds: [7],
};
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, true);
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
});
void test('keeps the access bundle projection-free for independent read-model plans', () => {
for (const slice of ['map', 'records', 'frontStatus', 'tournament'] as const) {
const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true };
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: false,
commandTable: false,
boardAccess: false,
});
}
});
void test('selects only the requested context bundle projections', () => {
const plan = {
...createEmptyRealtimeReadModelInvalidation(),
context: true,
commands: true,
};
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: true,
commandTable: true,
boardAccess: false,
});
});
void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => {
let nowMs = 0;
let nextTimerId = 1;
const timers = new Map<number, { callback: () => void; at: number }>();
const observed: Array<{ context: boolean; records: boolean; refreshGrant: string }> = [];
const queue = createMergedReadModelRefreshQueue(
async (invalidation, refreshGrant) => {
observed.push({ context: invalidation.context, records: invalidation.records, refreshGrant });
},
{
minIntervalMs: 1_000,
now: () => nowMs,
setTimer: (callback, delayMs) => {
const id = nextTimerId++;
timers.set(id, { callback, at: nowMs + delayMs });
return id as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: (timer) => timers.delete(timer as unknown as number),
}
);
const runDueTimers = () => {
for (const [id, timer] of [...timers]) {
if (timer.at <= nowMs) {
timers.delete(id);
timer.callback();
}
}
};
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-a');
runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [{ context: true, records: false, refreshGrant: 'grant-a' }]);
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-b');
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }, 'grant-c');
nowMs = 999;
runDueTimers();
assert.equal(observed.length, 1);
nowMs = 1_000;
runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [
{ context: true, records: false, refreshGrant: 'grant-a' },
{ context: true, records: true, refreshGrant: 'grant-c' },
]);
});