fix: 토너먼트 단계의 메인 실시간 갱신을 연결

토너먼트 state의 stage 전이만 공용 실시간 채널에 발행하고 공개 invalidation으로 변환한다.
메인 dashboard store가 단계 값을 소유해 상단 문구와 메뉴 강조를 단일/다중 탭에서 함께 갱신한다.
Redis 통합 테스트와 production Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-17 01:41:37 +00:00
parent 1a4cd1a53f
commit 50068224c8
17 changed files with 392 additions and 31 deletions
@@ -97,6 +97,7 @@ const readModelInvalidation = (
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
tournament: boolean;
}>
) => ({
context: false,
@@ -108,6 +109,7 @@ const readModelInvalidation = (
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
...overrides,
});
@@ -2300,6 +2302,22 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await expect(page.locator('.general-title')).toContainText('메뉴검증장수');
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
await expect
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]');
@@ -2352,6 +2370,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
});
}
@@ -2420,6 +2439,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
},
}
);
@@ -2577,6 +2597,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -2751,6 +2772,23 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
const followerPage = pages[followerIndex];
if (!leaderPage || !followerPage) throw new Error('realtime leader election failed');
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(leaderPage, readModelInvalidation({ tournament: true }));
await expect
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await Promise.all(
pages.map((currentPage) =>
expect(currentPage.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중')
)
);
await Promise.all(
pages.map((currentPage) =>
expect(currentPage.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u)
)
);
const callsBeforeSharedRefresh = state.generalMeCalls;
state.generalName = '탭공유갱신장수';
await leaderPage.evaluate(() => {
@@ -2767,6 +2805,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -2794,6 +2833,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
+20 -2
View File
@@ -46,6 +46,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext;
@@ -72,6 +73,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords?: RecentRecord[];
worldHistory?: RecentRecord[];
frontStatus?: FrontStatus | null;
tournamentStage?: number;
};
type DashboardTabMessage =
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
@@ -112,6 +114,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null);
const tournamentStage = ref(0);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
@@ -430,6 +433,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} else if (patch.frontStatus !== undefined) {
updateFrontStatus(patch.frontStatus);
}
if (patch.tournamentStage !== undefined) {
tournamentStage.value = patch.tournamentStage;
}
if (patch.contextRevision !== undefined) {
contextRevision = patch.contextRevision;
contextSourceRevision = patch.contextSourceRevision ?? null;
@@ -476,6 +482,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.generalRecords = toRaw(generalRecords.value);
patch.worldHistory = toRaw(worldHistory.value);
patch.frontStatus = toRaw(frontStatus.value);
patch.tournamentStage = tournamentStage.value;
return patch;
};
@@ -585,7 +592,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
frontStatusError.value = resolveErrorMessage(err);
return null;
});
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] =
const tournamentPromise = trpc.tournament.getState.query().catch(() => undefined);
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus, tournamentState] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
@@ -595,6 +603,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalTurnsPromise,
recordsPromise,
frontStatusPromise,
tournamentPromise,
]);
general.value = structurallyShare(general.value, context.general);
@@ -617,6 +626,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus);
}
if (tournamentState !== undefined) {
tournamentStage.value = tournamentState?.stage ?? 0;
}
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
@@ -705,14 +717,18 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return null;
})
: Promise.resolve(undefined);
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
? trpc.tournament.getState.query().catch(() => undefined)
: Promise.resolve(undefined);
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
lobbyPromise,
mapPromise,
contactsPromise,
reservedPromise,
recordsPromise,
frontPromise,
tournamentPromise,
]);
const patch: DashboardReadModelPatch = { ...contextPatch };
@@ -733,6 +749,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.worldHistory = nextWorldHistory;
}
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
@@ -1241,6 +1258,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
+2 -4
View File
@@ -27,7 +27,6 @@ const session = useSessionStore();
const dashboard = useMainDashboardStore();
const isMobile = useMediaQuery('(max-width: 939.98px)');
const tournamentStage = ref(0);
const npcMode = ref(0);
const {
@@ -52,6 +51,7 @@ const {
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
@@ -107,12 +107,10 @@ const repeatGeneralTurns = (amount: number) => {
};
const loadMainData = async () => {
const [, state, worldState] = await Promise.all([
const [, worldState] = await Promise.all([
dashboard.loadMainData(),
trpc.tournament.getState.query().catch(() => null),
trpc.world.getState.query().catch(() => null),
]);
tournamentStage.value = state?.stage ?? 0;
npcMode.value = worldState?.config.npcMode ?? 0;
};
@@ -25,6 +25,7 @@ void test('last-turn-time-only events do not schedule any dashboard query', () =
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
});
@@ -46,6 +47,7 @@ void test('selects only the read models affected by the current identity', () =>
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
});
});
@@ -79,6 +81,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const taxRate = resolveDashboardRefreshPlan(
@@ -100,6 +103,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const cityState = resolveDashboardRefreshPlan(
@@ -161,6 +165,7 @@ void test('refreshes only front status for a global survey projection change', (
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
});
});
@@ -174,8 +179,8 @@ void test('targets a submitted survey projection to its own general', () => {
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
});
void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => {
for (const slice of ['map', 'records', 'frontStatus'] as const) {
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,