From 918d8f0e4c0deba3b95d4cf01d7b7fad899a23c4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 08:55:06 +0000 Subject: [PATCH] feat: restore main record panels --- app/game-api/src/router/general/index.ts | 62 +++ app/game-api/test/mainRecordsRouter.test.ts | 133 +++++++ .../src/components/main/RecordPanel.vue | 41 ++ app/game-frontend/src/stores/mainDashboard.ts | 57 ++- app/game-frontend/src/views/MainView.vue | 161 ++++++-- .../main-records.playwright.config.mjs | 51 +++ .../main-records.spec.ts | 356 ++++++++++++++++++ .../reference-main-records.mjs | 91 +++++ .../run-main-records-live.mjs | 53 +++ 9 files changed, 971 insertions(+), 34 deletions(-) create mode 100644 app/game-api/test/mainRecordsRouter.test.ts create mode 100644 app/game-frontend/src/components/main/RecordPanel.vue create mode 100644 tools/frontend-legacy-parity/main-records.playwright.config.mjs create mode 100644 tools/frontend-legacy-parity/main-records.spec.ts create mode 100644 tools/frontend-legacy-parity/reference-main-records.mjs create mode 100644 tools/frontend-legacy-parity/run-main-records-live.mjs diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 4405b25..8294f99 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -15,6 +15,18 @@ const zGeneralSettings = z.object({ }); const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']); +const MAIN_RECORD_LIMIT = 15; + +const trimRecentRecords = (entries: Entry[], cursor: number): Entry[] => { + if (entries.length === 0) { + return entries; + } + const result = [...entries]; + if (result.at(-1)?.id === cursor || result.length > MAIN_RECORD_LIMIT) { + result.pop(); + } + return result; +}; const readNumber = (value: unknown, fallback: number): number => { if (typeof value === 'number' && Number.isFinite(value)) { @@ -319,4 +331,54 @@ export const generalRouter = router({ })), }; }), + getRecentRecords: authedProcedure + .input( + z.object({ + lastGeneralRecordId: z.number().int().nonnegative().default(0), + lastWorldHistoryId: z.number().int().nonnegative().default(0), + }) + ) + .query(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const take = MAIN_RECORD_LIMIT + 1; + const [global, general, history] = await Promise.all([ + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + id: { gte: input.lastGeneralRecordId }, + }, + orderBy: { id: 'desc' }, + take, + select: { id: true, text: true }, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: me.id, + id: { gte: input.lastGeneralRecordId }, + }, + orderBy: { id: 'desc' }, + take, + select: { id: true, text: true }, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + id: { gte: input.lastWorldHistoryId }, + }, + orderBy: { id: 'desc' }, + take, + select: { id: true, text: true }, + }), + ]); + + return { + global: trimRecentRecords(global, input.lastGeneralRecordId), + general: trimRecentRecords(general, input.lastGeneralRecordId), + history: trimRecentRecords(history, input.lastWorldHistoryId), + }; + }), }); diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts new file mode 100644 index 0000000..157353d --- /dev/null +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; + +import type { DatabaseClient, GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'session-owner', + user: { + id: 'owner', + username: 'owner', + displayName: 'Owner', + roles: [], + }, + sanctions: {}, +}; + +type LogQuery = { + where: { + scope: LogScope; + category: LogCategory; + generalId?: number; + id: { gte: number }; + }; + orderBy: { id: 'desc' }; + take: number; + select: { id: true; text: true }; +}; + +const buildContext = (findMany: (query: LogQuery) => Promise>) => + ({ + auth, + db: { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({ + id: 7, + userId: where.userId, + })), + }, + logEntry: { findMany }, + } as unknown as DatabaseClient, + }) as GameApiContext; + +describe('general.getRecentRecords', () => { + it('derives the general and maps all three legacy dashboard buckets', async () => { + const findMany = vi.fn(async (query: LogQuery) => { + if (query.where.scope === LogScope.GENERAL) { + return [ + { id: 31, text: '개인 최신' }, + { id: 20, text: '개인 cursor' }, + ]; + } + if (query.where.category === LogCategory.SUMMARY) { + return [ + { id: 32, text: '장수 최신' }, + { id: 20, text: '장수 cursor' }, + ]; + } + return [ + { id: 42, text: '중원 최신' }, + { id: 40, text: '중원 cursor' }, + ]; + }); + const caller = appRouter.createCaller(buildContext(findMany)); + + const result = await caller.general.getRecentRecords({ + lastGeneralRecordId: 20, + lastWorldHistoryId: 40, + }); + + expect(result).toEqual({ + global: [{ id: 32, text: '장수 최신' }], + general: [{ id: 31, text: '개인 최신' }], + history: [{ id: 42, text: '중원 최신' }], + }); + expect(findMany).toHaveBeenCalledTimes(3); + expect(findMany).toHaveBeenCalledWith({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 7, + id: { gte: 20 }, + }, + orderBy: { id: 'desc' }, + take: 16, + select: { id: true, text: true }, + }); + }); + + it('caps an initial bucket at the legacy 15-row limit', async () => { + const rows = Array.from({ length: 16 }, (_, index) => ({ + id: 100 - index, + text: `기록 ${index}`, + })); + const caller = appRouter.createCaller(buildContext(async () => rows)); + + const result = await caller.general.getRecentRecords({ + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }); + + expect(result.global).toHaveLength(15); + expect(result.general).toHaveLength(15); + expect(result.history).toHaveLength(15); + expect(result.global.at(-1)?.id).toBe(86); + }); + + it('rejects an authenticated user without an in-game general', async () => { + const context = buildContext(async () => []); + context.db = { + general: { + findFirst: vi.fn(async () => null), + }, + logEntry: { + findMany: vi.fn(async () => []), + }, + } as unknown as DatabaseClient; + const caller = appRouter.createCaller(context); + + await expect( + caller.general.getRecentRecords({ + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/app/game-frontend/src/components/main/RecordPanel.vue b/app/game-frontend/src/components/main/RecordPanel.vue new file mode 100644 index 0000000..b90d672 --- /dev/null +++ b/app/game-frontend/src/components/main/RecordPanel.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 9f5803d..bb0df62 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -26,9 +26,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MessageContacts = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; + type RecentRecord = Awaited>['global'][number]; const loading = ref(false); const error = ref(null); + const recordsError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); @@ -42,6 +44,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); + const globalRecords = ref([]); + const generalRecords = ref([]); + const worldHistory = ref([]); + let lastGeneralRecordId = 0; + let lastWorldHistoryId = 0; + let recordGeneralId: number | null = null; const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); @@ -191,12 +199,30 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { + const merged = new Map(current.map((entry) => [entry.id, entry])); + for (const entry of incoming) { + merged.set(entry.id, entry); + } + return [...merged.values()].sort((left, right) => right.id - left.id).slice(0, 15); + }; + + const resetRecentRecords = (id: number | null) => { + globalRecords.value = []; + generalRecords.value = []; + worldHistory.value = []; + lastGeneralRecordId = 0; + lastWorldHistoryId = 0; + recordGeneralId = id; + }; + const loadMainData = async () => { if (loading.value) { return; } loading.value = true; error.value = null; + recordsError.value = null; try { const context = await trpc.general.me.query(); @@ -206,18 +232,31 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { reservedGeneralTurns.value = null; reservedNationTurns.value = null; boardAccess.value = null; + resetRecentRecords(null); loading.value = false; return; } const id = context.general.id; + if (recordGeneralId !== id) { + resetRecentRecords(id); + } const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query(); const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id }); const nationTurnsPromise = context.general.nationId > 0 && context.general.officerLevel >= 5 ? trpc.turns.reserved.getNation.query({ generalId: id }) : Promise.resolve(null); - const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] = + const recordsPromise = trpc.general.getRecentRecords + .query({ + lastGeneralRecordId, + lastWorldHistoryId, + }) + .catch((err: unknown) => { + recordsError.value = resolveErrorMessage(err); + return null; + }); + const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), @@ -228,6 +267,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { trpc.board.getAccess.query(), generalTurnsPromise, nationTurnsPromise, + recordsPromise, ]); mapLayout.value = layout; @@ -239,6 +279,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = access; reservedGeneralTurns.value = generalTurns; reservedNationTurns.value = nationTurns; + if (records) { + globalRecords.value = mergeRecentRecords(globalRecords.value, records.global); + generalRecords.value = mergeRecentRecords(generalRecords.value, records.general); + worldHistory.value = mergeRecentRecords(worldHistory.value, records.history); + lastGeneralRecordId = Math.max( + lastGeneralRecordId, + records.global[0]?.id ?? 0, + records.general[0]?.id ?? 0 + ); + lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0); + } if (initializedMailboxGeneralId !== id) { targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; initializedMailboxGeneralId = id; @@ -587,6 +638,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return { loading, error, + recordsError, realtimeEnabled, realtimeStatus, generalContext, @@ -603,6 +655,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess, reservedGeneralTurns, reservedNationTurns, + globalRecords, + generalRecords, + worldHistory, messageDraftText, targetMailbox, mailboxGroups, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index e1592f2..e166db4 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -11,13 +11,15 @@ import CityBasicCard from '../components/main/CityBasicCard.vue'; import NationBasicCard from '../components/main/NationBasicCard.vue'; import MessagePanel from '../components/main/MessagePanel.vue'; import SelectedCityPanel from '../components/main/SelectedCityPanel.vue'; +import RecordPanel from '../components/main/RecordPanel.vue'; +import { formatLog } from '../utils/formatLog'; import { useSessionStore } from '../stores/session'; import { useMainDashboardStore } from '../stores/mainDashboard'; import { trpc } from '../utils/trpc'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); -const isMobile = useMediaQuery('(max-width: 1024px)'); +const isMobile = useMediaQuery('(max-width: 991px)'); const mobileTabs = [ { key: 'map', label: '지도' }, @@ -35,11 +37,11 @@ const tournamentStage = ref(0); const { loading, error, + recordsError, realtimeEnabled, general, city, nation, - lobbyInfo, worldMap, mapLayout, selectedCity, @@ -48,6 +50,9 @@ const { boardAccess, reservedGeneralTurns, reservedNationTurns, + globalRecords, + generalRecords, + worldHistory, messageDraftText, targetMailbox, mailboxGroups, @@ -203,23 +208,49 @@ watch( -
- +
+ -
장수 동향은 실시간 스트림으로 연결 예정
- - - -
개인 기록 영역
-
- - -
-
유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}
-
NPC {{ lobbyInfo?.npcCnt ?? '-' }}
-
세력 {{ lobbyInfo?.nationCnt ?? '-' }}
+ +
+ +
+
기록이 없습니다.
- + + + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+
@@ -254,14 +285,6 @@ watch( - - -
-
유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}
-
NPC {{ lobbyInfo?.npcCnt ?? '-' }}
-
세력 {{ lobbyInfo?.nationCnt ?? '-' }}
-
-
@@ -282,21 +305,57 @@ watch( - - -
장수 동향은 실시간 스트림으로 연결 예정
-
- - -
개인 기록 영역
-
+
+ + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ +
; +let redis: RedisConnector; +let prisma: GamePrismaClient; +let createdIds: number[] = []; + +const accessKey = (token: string) => `sammo:game:access:che:default:${token}`; +const trpcResponse = (data: unknown) => ({ result: { data } }); +const trpcError = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path }, + }, +}); +const operationNames = (route: Route): string[] => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname); + return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(','); +}; + +const inspectRecordLayout = async (page: Page) => + page.evaluate(() => { + const inspect = (selector: string) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) { + throw new Error(`missing ${selector}`); + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + padding: style.padding, + color: style.color, + backgroundImage: style.backgroundImage, + borderTop: style.borderTop, + borderBottom: style.borderBottom, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + lineHeight: style.lineHeight, + textAlign: style.textAlign, + }; + }; + const buckets = [...document.querySelectorAll('[data-record-bucket]')]; + const mobileZone = document.querySelector('.record-zone-mobile'); + const zoneSelector = + mobileZone && getComputedStyle(mobileZone).display !== 'none' ? '.record-zone-mobile' : '.record-zone'; + return { + zone: inspect(zoneSelector), + global: inspect('[data-record-bucket="global"]'), + general: inspect('[data-record-bucket="general"]'), + history: inspect('[data-record-bucket="history"]'), + title: inspect('[data-record-bucket="global"]'), + firstLine: inspect('[data-record-bucket="global"] .record-line'), + panels: buckets.map((bucket) => { + const panel = bucket.closest('.record-panel'); + const title = panel?.querySelector('.record-title'); + if (!panel || !title) throw new Error('record panel structure missing'); + return { + bucket: bucket.dataset.recordBucket, + panel: panel.getBoundingClientRect().toJSON(), + title: title.getBoundingClientRect().toJSON(), + lineCount: bucket.querySelectorAll('.record-line').length, + titleStyle: { + fontSize: getComputedStyle(title).fontSize, + fontWeight: getComputedStyle(title).fontWeight, + lineHeight: getComputedStyle(title).lineHeight, + textAlign: getComputedStyle(title).textAlign, + borderTop: getComputedStyle(title).borderTop, + borderBottom: getComputedStyle(title).borderBottom, + }, + }; + }), + }; + }); + +test.beforeAll(async () => { + postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' })); + redis = createRedisConnector(resolveRedisConfigFromEnv()); + await postgres.connect(); + await redis.connect(); + prisma = postgres.prisma; + + const general = await prisma.general.findFirst({ + where: { userId: { not: null } }, + orderBy: { id: 'asc' }, + select: { id: true, userId: true, name: true }, + }); + if (!general?.userId) { + throw new Error('The live che profile needs an owned general.'); + } + const world = await prisma.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { currentYear: true, currentMonth: true }, + }); + if (!world) { + throw new Error('The live che profile needs a world state.'); + } + + const drafts = [ + ...Array.from({ length: 15 }, (_, index) => ({ + scope: 'SYSTEM' as const, + category: 'SUMMARY' as const, + text: `●${RECORD_MARKER} 장수 동향 ${15 - index}`, + })), + ...Array.from({ length: 7 }, (_, index) => ({ + scope: 'GENERAL' as const, + category: 'ACTION' as const, + generalId: general.id, + text: `●${RECORD_MARKER} 개인 기록 ${7 - index}`, + })), + ...Array.from({ length: 15 }, (_, index) => ({ + scope: 'SYSTEM' as const, + category: 'HISTORY' as const, + text: `●${RECORD_MARKER} 중원 정세 ${15 - index}`, + })), + ]; + const rows = await Promise.all( + drafts.map((draft) => + prisma.logEntry.create({ + data: { + ...draft, + year: world.currentYear, + month: world.currentMonth, + meta: { fixture: RECORD_MARKER }, + }, + select: { id: true }, + }) + ) + ); + createdIds = rows.map((row: { id: number }) => row.id); + + const issuedAt = new Date(); + const expiresAt = new Date(issuedAt.getTime() + 30 * 60 * 1000); + await redis.client.set( + accessKey(accessToken), + JSON.stringify({ + version: 1, + profile: 'che:default', + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString(), + sessionId: `main-records-${randomUUID()}`, + user: { + id: general.userId, + username: 'main-records-live', + displayName: general.name, + roles: [], + }, + sanctions: {}, + }), + { EX: 1800 } + ); +}); + +test.afterAll(async () => { + if (createdIds.length > 0) { + await prisma.logEntry.deleteMany({ where: { id: { in: createdIds } } }); + } + await redis.client.del(accessKey(accessToken)); + await redis.disconnect(); + await postgres.disconnect(); +}); + +test('renders all three real database record buckets with legacy computed geometry', async ({ page }) => { + const liveResponse = await page.request.get( + `http://127.0.0.1:15113/che/api/trpc/general.getRecentRecords?input=${encodeURIComponent( + JSON.stringify({ + json: { + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }, + }) + )}`, + { headers: { authorization: `Bearer ${accessToken}` } } + ); + expect(liveResponse.ok()).toBe(true); + const livePayload = (await liveResponse.json()) as { + result: { data: { global: unknown[]; general: unknown[]; history: unknown[] } }; + }; + const liveRecords = livePayload.result.data; + expect([liveRecords.global.length, liveRecords.general.length, liveRecords.history.length]).toEqual([15, 7, 15]); + let failRecords = false; + + await page.addInitScript( + ({ token }) => { + window.localStorage.setItem('sammo-game-token', token); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }, + { token: accessToken } + ); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'general.me') { + return trpcResponse({ + general: { + id: 1, + name: '관리자', + npcState: 0, + nationId: 0, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: 0, + stats: { leadership: 55, strength: 55, intelligence: 55 }, + gold: 1000, + rice: 1000, + crew: 0, + train: 0, + atmos: 0, + injury: 0, + experience: 0, + dedication: 0, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: null, + nation: null, + settings: {}, + penalties: {}, + }); + } + if (operation === 'general.getRecentRecords') { + return failRecords + ? trpcError(operation, '동향 정보를 불러오지 못했습니다.') + : trpcResponse(liveRecords); + } + if (operation === 'lobby.info') { + return trpcResponse({ + year: 190, + month: 1, + userCnt: 1, + npcCnt: 0, + nationCnt: 0, + maxUserCnt: 50, + turnTerm: 60, + fictionMode: '가상', + otherTextInfo: '', + starttime: '0190-01-01T00:00:00.000Z', + myGeneral: { id: 1, name: '관리자', nationId: 0 }, + }); + } + if (operation === 'world.getMapLayout') { + return trpcResponse({ + mapName: 'che', + cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }], + regionMap: { 1: '사예' }, + levelMap: { 7: '수도' }, + }); + } + if (operation === 'world.getMap') { + return trpcResponse({ + year: 190, + month: 1, + startYear: 190, + cityList: [[1, 7, 0, 0, 1, 1]], + nationList: [], + spyList: {}, + shownByGeneralList: [], + myCity: 1, + myNation: 0, + }); + } + if (operation === 'turns.getCommandTable') return trpcResponse({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { + return trpcResponse([]); + } + if (operation === 'messages.getRecent') { + return trpcResponse({ + private: [], + public: [], + national: [], + diplomacy: [], + permission: 0, + latestRead: { private: 0, diplomacy: 0 }, + canRespondDiplomacy: false, + }); + } + if (operation === 'messages.getContacts') return trpcResponse([]); + if (operation === 'board.getAccess') { + return trpcResponse({ canMeeting: false, canSecret: false, permission: 0 }); + } + if (operation === 'tournament.getState') return trpcResponse({ stage: 0 }); + if (operation === 'public.recordAccess') return trpcResponse({ recorded: true }); + throw new Error(`Unhandled live main operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + await page.route('**/image/**', async (route) => { + const source = new URL(route.request().url()); + const response = await route.fetch({ + url: `https://dev-sam-ref.hided.net${source.pathname}`, + }); + await route.fulfill({ response }); + }); + + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('./', { waitUntil: 'networkidle' }); + await expect(page.locator('[data-record-bucket="global"] .record-line')).toHaveCount(15); + await expect(page.locator('[data-record-bucket="general"] .record-line')).toHaveCount(7); + await expect(page.locator('[data-record-bucket="history"] .record-line')).toHaveCount(15); + await expect(page.locator('[data-record-bucket="global"]')).toContainText(RECORD_MARKER); + + const desktop = await inspectRecordLayout(page); + expect(desktop.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]); + expect(desktop.panels[0]?.panel.width).toBeCloseTo(500, 0); + expect(desktop.panels[1]?.panel.x).toBeCloseTo(500, 0); + expect(desktop.panels[2]?.panel.width).toBeCloseTo(1000, 0); + expect(desktop.panels[0]?.panel.height).toBeCloseTo(338, 0); + expect(desktop.panels[2]?.panel.height).toBeCloseTo(338, 0); + expect(desktop.panels[0]?.title.height).toBeCloseTo(23, 0); + expect(desktop.panels[0]?.titleStyle).toMatchObject({ + fontSize: '14px', + fontWeight: '400', + lineHeight: '21px', + textAlign: 'center', + borderTop: '1px solid rgb(128, 128, 128)', + borderBottom: '1px solid rgb(128, 128, 128)', + }); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.getByRole('button', { name: '동향', exact: true }).click(); + await expect(page.locator('.record-zone-mobile')).toBeVisible(); + const mobile = await inspectRecordLayout(page); + expect(mobile.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]); + expect(mobile.panels.every((panel) => Math.round(panel.panel.width) === 500)).toBe(true); + expect(mobile.panels[0]?.panel.height).toBeCloseTo(338, 0); + expect(mobile.panels[1]?.panel.height).toBeCloseTo(170, 0); + expect(mobile.panels[2]?.panel.height).toBeCloseTo(338, 0); + + failRecords = true; + await page.getByRole('button', { name: '새로고침', exact: true }).click(); + await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.'); + await expect(page.getByRole('heading', { name: '장수 동향', exact: true })).toBeVisible(); +}); diff --git a/tools/frontend-legacy-parity/reference-main-records.mjs b/tools/frontend-legacy-parity/reference-main-records.mjs new file mode 100644 index 0000000..99f67d2 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-main-records.mjs @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +import { chromium } from '@playwright/test'; + +const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_USER_ID ?? 'refuser1'; +const passwordFile = + process.env.REF_USER_PASSWORD_FILE ?? + '/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password'; + +const password = (await readFile(passwordFile, 'utf8')).trim(); +const browser = await chromium.launch({ headless: true }); + +try { + const context = await browser.newContext({ + colorScheme: 'dark', + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'UTC', + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + if (!response.ok()) { + throw new Error(`reference login failed: HTTP ${response.status()}`); + } + + for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + await page.locator('.RecordZone').waitFor({ state: 'visible' }); + await page.waitForFunction(() => + [...document.querySelectorAll('.RecordZone > div')].every((panel) => panel.children.length > 1) + ); + const measurement = await page.evaluate(() => { + const inspect = (selector) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) { + throw new Error(`missing ${selector}`); + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + display: style.display, + padding: style.padding, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderTop: style.borderTop, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + lineHeight: style.lineHeight, + textAlign: style.textAlign, + }; + }; + return { + zone: inspect('.RecordZone'), + global: inspect('.PublicRecord'), + general: inspect('.GeneralLog'), + history: inspect('.WorldHistory'), + title: inspect('.PublicRecord .title'), + firstLine: inspect('.PublicRecord > div:nth-child(2)'), + counts: { + global: document.querySelectorAll('.PublicRecord > div:not(.title)').length, + general: document.querySelectorAll('.GeneralLog > div:not(.title)').length, + history: document.querySelectorAll('.WorldHistory > div:not(.title)').length, + }, + }; + }); + console.log(JSON.stringify({ viewport, measurement })); + } +} finally { + await browser.close(); +} diff --git a/tools/frontend-legacy-parity/run-main-records-live.mjs b/tools/frontend-legacy-parity/run-main-records-live.mjs new file mode 100644 index 0000000..363ef8e --- /dev/null +++ b/tools/frontend-legacy-parity/run-main-records-live.mjs @@ -0,0 +1,53 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const pm2Candidates = [ + '/home/letrhee/.nvm/versions/node/v22.15.1/bin/pm2', + '/home/letrhee/.nvm/versions/node/v24.13.0/bin/pm2', +]; + +let processes; +for (const candidate of pm2Candidates) { + try { + const output = execFileSync(candidate, ['jlist'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + processes = JSON.parse(output.slice(output.indexOf('['))); + break; + } catch { + // Try the next locally installed PM2 client. + } +} +if (!processes) { + throw new Error('Unable to read the local PM2 process list.'); +} + +const source = processes.find((process) => process.name === 'sammo-verify-che-api')?.pm2_env; +if (!source) { + throw new Error('sammo-verify-che-api is required as the live environment source.'); +} + +const allowed = /^(DATABASE_URL|POSTGRES_|REDIS_|GAME_|GATEWAY_|PROFILE$|SCENARIO$)/; +const liveEnvironment = Object.fromEntries( + Object.entries(source).filter(([key, value]) => allowed.test(key) && typeof value === 'string') +); +const result = spawnSync( + 'pnpm', + [ + 'exec', + 'playwright', + 'test', + 'main-records.spec.ts', + '--config', + 'tools/frontend-legacy-parity/main-records.playwright.config.mjs', + ], + { + cwd: repositoryRoot, + env: { ...process.env, ...liveEnvironment }, + stdio: 'inherit', + } +); +process.exitCode = result.status ?? 1;