merge: bound HWE realtime dashboard refreshes
This commit is contained in:
@@ -239,6 +239,10 @@ const installRealtimeHarness = async (page: Page) => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(window, '__hasMainRealtime', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => TestEventSource.latest !== null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -513,7 +517,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
|||||||
expect(state.operations).not.toContain('auth.logout');
|
expect(state.operations).not.toContain('auth.logout');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('turn realtime refresh keeps rendered panels mounted and patches only changed state', async ({ page }) => {
|
test('turn realtime refresh is rate limited, patches in place, and stops after leaving main', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
permission: 2,
|
permission: 2,
|
||||||
@@ -564,23 +568,19 @@ test('turn realtime refresh keeps rendered panels mounted and patches only chang
|
|||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
||||||
.__emitMainRealtime;
|
.__emitMainRealtime;
|
||||||
emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() });
|
for (let index = 0; index < 100; index += 1) {
|
||||||
emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() });
|
emit('turnCompleted', { at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z' });
|
||||||
emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() });
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.poll(() => state.generalMeCalls).toBe(callsBeforeRefresh + 1);
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
expect(state.generalMeCalls).toBe(callsBeforeRefresh);
|
||||||
|
await expect.poll(() => state.generalMeCalls, { timeout: 7_000 }).toBe(callsBeforeRefresh + 1);
|
||||||
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
||||||
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'true');
|
|
||||||
if (autoRefreshArtifactRoot) {
|
|
||||||
await mkdir(resolve(autoRefreshArtifactRoot), { recursive: true });
|
|
||||||
await page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-in-flight.png'), fullPage: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect.poll(() => state.generalMeCalls, { timeout: 5_000 }).toBe(callsBeforeRefresh + 2);
|
|
||||||
await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수');
|
|
||||||
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
|
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
|
||||||
|
expect(state.generalMeCalls).toBe(callsBeforeRefresh + 1);
|
||||||
|
await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수');
|
||||||
|
|
||||||
const profile = await page.evaluate(() => {
|
const profile = await page.evaluate(() => {
|
||||||
const probe = (
|
const probe = (
|
||||||
@@ -615,7 +615,7 @@ test('turn realtime refresh keeps rendered panels mounted and patches only chang
|
|||||||
resolve(autoRefreshArtifactRoot, 'profile.json'),
|
resolve(autoRefreshArtifactRoot, 'profile.json'),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
emittedTurnEvents: 3,
|
emittedTurnEvents: 100,
|
||||||
refreshRequests: state.generalMeCalls - callsBeforeRefresh,
|
refreshRequests: state.generalMeCalls - callsBeforeRefresh,
|
||||||
inFlightSkeletons: { general: 0, city: 0 },
|
inFlightSkeletons: { general: 0, city: 0 },
|
||||||
...profile,
|
...profile,
|
||||||
@@ -626,4 +626,22 @@ test('turn realtime refresh keeps rendered panels mounted and patches only chang
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.locator(`a[href="${basePath}/board"]`).first().click();
|
||||||
|
await page.waitForURL(`**${basePath}/board`);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
const callsAfterLeavingMain = state.generalMeCalls;
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||||
|
'turnCompleted',
|
||||||
|
{ at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { trpc } from '../utils/trpc';
|
|||||||
import { useMapViewerStore } from './mapViewer';
|
import { useMapViewerStore } from './mapViewer';
|
||||||
import { useSessionStore } from './session';
|
import { useSessionStore } from './session';
|
||||||
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
|
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
|
||||||
|
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
|
||||||
import { structurallyShare } from '../utils/structuralShare';
|
import { structurallyShare } from '../utils/structuralShare';
|
||||||
|
|
||||||
|
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
return value.message;
|
return value.message;
|
||||||
@@ -39,6 +42,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const frontStatusError = ref<string | null>(null);
|
const frontStatusError = ref<string | null>(null);
|
||||||
const realtimeEnabled = ref(true);
|
const realtimeEnabled = ref(true);
|
||||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||||
|
const realtimeActive = ref(false);
|
||||||
|
|
||||||
const general = ref<PresentGeneralContext['general'] | null>(null);
|
const general = ref<PresentGeneralContext['general'] | null>(null);
|
||||||
const city = ref<PresentGeneralContext['city'] | null>(null);
|
const city = ref<PresentGeneralContext['city'] | null>(null);
|
||||||
@@ -373,6 +377,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
|
|
||||||
const refreshQueue = createLatestRefreshQueue(refreshMainData);
|
const refreshQueue = createLatestRefreshQueue(refreshMainData);
|
||||||
const loadMainData = () => refreshQueue.request();
|
const loadMainData = () => refreshQueue.request();
|
||||||
|
const realtimeRefreshQueue = createRateLimitedRefreshQueue(() => refreshQueue.request(), {
|
||||||
|
minIntervalMs: REALTIME_FULL_REFRESH_MIN_INTERVAL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
const refreshMessages = async () => {
|
const refreshMessages = async () => {
|
||||||
const id = generalId.value;
|
const id = generalId.value;
|
||||||
@@ -593,6 +600,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
|
|
||||||
let realtimeSource: EventSource | null = null;
|
let realtimeSource: EventSource | null = null;
|
||||||
let realtimeToken: string | null = null;
|
let realtimeToken: string | null = null;
|
||||||
|
let visibilityListenerInstalled = false;
|
||||||
|
|
||||||
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
||||||
|
|
||||||
@@ -663,7 +671,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!realtimeEnabled.value || !session.isReady || !session.hasGeneral) {
|
if (
|
||||||
|
!realtimeActive.value ||
|
||||||
|
document.visibilityState === 'hidden' ||
|
||||||
|
!realtimeEnabled.value ||
|
||||||
|
!session.isReady ||
|
||||||
|
!session.hasGeneral
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = await ensureAccessToken();
|
const token = await ensureAccessToken();
|
||||||
@@ -688,7 +702,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||||
});
|
});
|
||||||
source.addEventListener('turnCompleted', () => {
|
source.addEventListener('turnCompleted', () => {
|
||||||
void loadMainData();
|
realtimeRefreshQueue.request();
|
||||||
});
|
});
|
||||||
source.addEventListener('messageCreated', (event) => {
|
source.addEventListener('messageCreated', (event) => {
|
||||||
const payload = parseRealtimePayload(event);
|
const payload = parseRealtimePayload(event);
|
||||||
@@ -706,9 +720,48 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (!realtimeActive.value) return;
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
realtimeRefreshQueue.cancelPending();
|
||||||
|
closeRealtimeSource();
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
realtimeRefreshQueue.beginCooldown();
|
||||||
|
void connectRealtime();
|
||||||
|
realtimeRefreshQueue.request();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRealtime = () => {
|
||||||
|
if (typeof window === 'undefined' || realtimeActive.value) return;
|
||||||
|
realtimeActive.value = true;
|
||||||
|
realtimeRefreshQueue.beginCooldown();
|
||||||
|
if (!visibilityListenerInstalled) {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
visibilityListenerInstalled = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRealtime = () => {
|
||||||
|
realtimeActive.value = false;
|
||||||
|
realtimeRefreshQueue.cancelPending();
|
||||||
|
closeRealtimeSource();
|
||||||
|
if (visibilityListenerInstalled) {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
visibilityListenerInstalled = false;
|
||||||
|
}
|
||||||
|
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken],
|
() => [realtimeActive.value, realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken],
|
||||||
([enabled, ready, hasGeneral]) => {
|
([active, enabled, ready, hasGeneral]) => {
|
||||||
|
if (!active) {
|
||||||
|
closeRealtimeSource();
|
||||||
|
realtimeStatus.value = enabled ? 'idle' : 'paused';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
closeRealtimeSource();
|
closeRealtimeSource();
|
||||||
realtimeStatus.value = 'paused';
|
realtimeStatus.value = 'paused';
|
||||||
@@ -720,8 +773,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void connectRealtime();
|
void connectRealtime();
|
||||||
},
|
}
|
||||||
{ immediate: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -755,6 +807,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
statusLine,
|
statusLine,
|
||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
setRealtimeEnabled,
|
setRealtimeEnabled,
|
||||||
|
startRealtime,
|
||||||
|
stopRealtime,
|
||||||
dismissSurveyNotice,
|
dismissSurveyNotice,
|
||||||
loadMainData,
|
loadMainData,
|
||||||
refreshMessages,
|
refreshMessages,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type RateLimitedRefreshQueue = {
|
||||||
|
request: () => void;
|
||||||
|
beginCooldown: () => void;
|
||||||
|
cancelPending: () => void;
|
||||||
|
isRunning: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
export type RateLimitedRefreshQueueOptions = {
|
||||||
|
minIntervalMs: number;
|
||||||
|
now?: () => number;
|
||||||
|
setTimer?: (callback: () => void, delayMs: number) => TimerHandle;
|
||||||
|
clearTimer?: (timer: TimerHandle) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a sustained notification stream from turning into a sustained request
|
||||||
|
* stream. One trailing refresh is retained, while starts are bounded by the
|
||||||
|
* configured interval.
|
||||||
|
*/
|
||||||
|
export const createRateLimitedRefreshQueue = (
|
||||||
|
refresh: () => Promise<void>,
|
||||||
|
options: RateLimitedRefreshQueueOptions
|
||||||
|
): RateLimitedRefreshQueue => {
|
||||||
|
const minIntervalMs = Math.max(0, options.minIntervalMs);
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||||
|
const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
|
||||||
|
|
||||||
|
let active = false;
|
||||||
|
let pending = false;
|
||||||
|
let timer: TimerHandle | null = null;
|
||||||
|
let lastStartedAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (!pending || active || timer) return;
|
||||||
|
const delayMs = Math.max(0, lastStartedAt + minIntervalMs - now());
|
||||||
|
timer = setTimer(() => {
|
||||||
|
timer = null;
|
||||||
|
if (!pending) return;
|
||||||
|
pending = false;
|
||||||
|
active = true;
|
||||||
|
lastStartedAt = now();
|
||||||
|
void refresh().finally(() => {
|
||||||
|
active = false;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: () => {
|
||||||
|
pending = true;
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
beginCooldown: () => {
|
||||||
|
lastStartedAt = now();
|
||||||
|
},
|
||||||
|
cancelPending: () => {
|
||||||
|
pending = false;
|
||||||
|
if (timer) {
|
||||||
|
clearTimer(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isRunning: () => active || timer !== null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
@@ -92,6 +92,11 @@ onUnmounted(() => {
|
|||||||
if (surveyNoticeTimer) {
|
if (surveyNoticeTimer) {
|
||||||
clearTimeout(surveyNoticeTimer);
|
clearTimeout(surveyNoticeTimer);
|
||||||
}
|
}
|
||||||
|
dashboard.stopRealtime();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
dashboard.startRealtime();
|
||||||
});
|
});
|
||||||
|
|
||||||
const shiftGeneralTurns = (amount: number) => {
|
const shiftGeneralTurns = (amount: number) => {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createRateLimitedRefreshQueue } from '../src/utils/rateLimitedRefreshQueue.ts';
|
||||||
|
|
||||||
|
void test('bounds a sustained event burst and retains one trailing refresh', async () => {
|
||||||
|
let nowMs = 0;
|
||||||
|
let runs = 0;
|
||||||
|
let nextTimerId = 1;
|
||||||
|
const timers = new Map<number, { callback: () => void; at: number }>();
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
const queue = createRateLimitedRefreshQueue(
|
||||||
|
async () => {
|
||||||
|
runs += 1;
|
||||||
|
await new Promise<void>((resolve) => releases.push(resolve));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minIntervalMs: 5_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();
|
||||||
|
runDueTimers();
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
|
||||||
|
for (let index = 0; index < 100; index += 1) queue.request();
|
||||||
|
releases.shift()?.();
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
assert.equal(timers.size, 1);
|
||||||
|
|
||||||
|
nowMs = 4_999;
|
||||||
|
runDueTimers();
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
nowMs = 5_000;
|
||||||
|
runDueTimers();
|
||||||
|
assert.equal(runs, 2);
|
||||||
|
|
||||||
|
releases.shift()?.();
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(queue.isRunning(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('cancels a pending trailing refresh', () => {
|
||||||
|
let timerCallback: (() => void) | null = null;
|
||||||
|
let runs = 0;
|
||||||
|
const queue = createRateLimitedRefreshQueue(async () => {
|
||||||
|
runs += 1;
|
||||||
|
}, {
|
||||||
|
minIntervalMs: 5_000,
|
||||||
|
now: () => 0,
|
||||||
|
setTimer: (callback) => {
|
||||||
|
timerCallback = callback;
|
||||||
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
clearTimer: () => {
|
||||||
|
timerCallback = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.beginCooldown();
|
||||||
|
queue.request();
|
||||||
|
queue.cancelPending();
|
||||||
|
assert.equal(timerCallback, null);
|
||||||
|
assert.equal(runs, 0);
|
||||||
|
assert.equal(queue.isRunning(), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user