fix: 토너먼트 단계의 메인 실시간 갱신을 연결
토너먼트 state의 stage 전이만 공용 실시간 채널에 발행하고 공개 invalidation으로 변환한다. 메인 dashboard store가 단계 값을 소유해 상단 문구와 메뉴 강조를 단일/다중 탭에서 함께 갱신한다. Redis 통합 테스트와 production Chromium 회귀 검증을 추가한다.
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user