feat: restore main record panels

This commit is contained in:
2026-07-26 08:55:06 +00:00
parent b9d22c75f5
commit 918d8f0e4c
9 changed files with 971 additions and 34 deletions
+62
View File
@@ -15,6 +15,18 @@ const zGeneralSettings = z.object({
});
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
const MAIN_RECORD_LIMIT = 15;
const trimRecentRecords = <Entry extends { id: number }>(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),
};
}),
});
+133
View File
@@ -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<Array<{ id: number; text: string }>>) =>
({
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' });
});
});
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
title: string;
}>();
</script>
<template>
<section class="record-panel">
<h2 class="record-title">{{ title }}</h2>
<div class="record-body">
<slot />
</div>
</section>
</template>
<style scoped>
.record-panel {
min-width: 0;
color: #fff;
font-size: 14px;
font-weight: 400;
line-height: 21px;
}
.record-title {
box-sizing: border-box;
height: 23px;
margin: 0;
border-top: 1px solid gray;
border-bottom: 1px solid gray;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
color: #fff;
font: inherit;
text-align: center;
}
.record-body {
padding: 0;
}
</style>
+56 -1
View File
@@ -26,9 +26,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
const loading = ref(false);
const error = ref<string | null>(null);
const recordsError = ref<string | null>(null);
const realtimeEnabled = ref(true);
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
@@ -42,6 +44,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const globalRecords = ref<RecentRecord[]>([]);
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
let recordGeneralId: number | null = null;
const messageDraftText = ref('');
const targetMailbox = ref<number>(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,
+128 -33
View File
@@ -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(
</PanelCard>
</div>
<div v-if="mobileTab === 'world'" class="mobile-panel">
<PanelCard title="장수 동향">
<div v-if="mobileTab === 'world'" class="mobile-panel record-zone-mobile">
<RecordPanel title="장수 동향">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
</PanelCard>
<PanelCard title="개인 기록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">개인 기록 영역</div>
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="global">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-for="entry in globalRecords"
:key="entry.id"
class="record-line"
v-html="formatLog(entry.text)"
/>
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</PanelCard>
</RecordPanel>
<RecordPanel title="개인 기록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="general">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-for="entry in generalRecords"
:key="entry.id"
class="record-line"
v-html="formatLog(entry.text)"
/>
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel title="중원 정세">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="history">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-for="entry in worldHistory"
:key="entry.id"
class="record-line"
v-html="formatLog(entry.text)"
/>
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
</div>
<div v-if="mobileTab === 'messages'" class="mobile-panel">
@@ -254,14 +285,6 @@ watch(
<PanelCard title="선택 도시">
<SelectedCityPanel :city="selectedCity" :loading="loading" />
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="placeholder">
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div>
</PanelCard>
</div>
<div class="stack">
@@ -282,21 +305,57 @@ watch(
<PanelCard title="장수 스탯">
<GeneralBasicCard :general="general" :loading="loading" />
</PanelCard>
<PanelCard title="장수 동향">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
</PanelCard>
<PanelCard title="도시 정보">
<CityBasicCard :city="city" :loading="loading" />
</PanelCard>
<PanelCard title="국가 정보">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="개인 기록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">개인 기록 영역</div>
</PanelCard>
</div>
<section class="record-zone">
<RecordPanel title="장수 동향">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="global">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-for="entry in globalRecords"
:key="entry.id"
class="record-line"
v-html="formatLog(entry.text)"
/>
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel title="개인 기록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="general">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-for="entry in generalRecords"
:key="entry.id"
class="record-line"
v-html="formatLog(entry.text)"
/>
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel class="world-history-panel" title="중원 정세">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="history">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-for="entry in worldHistory"
:key="entry.id"
class="record-line"
v-html="formatLog(entry.text)"
/>
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
</section>
<MessagePanel
class="desktop-message-panel"
:messages="messages"
@@ -415,6 +474,42 @@ button {
grid-column: 1 / -1;
}
.record-zone {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
width: calc(100% + 48px);
margin-left: -24px;
}
.world-history-panel {
grid-column: 1 / -1;
}
.record-list {
min-height: 21px;
line-height: 21px;
}
.record-line {
overflow-wrap: anywhere;
}
.record-empty {
color: #aaa;
}
.record-error {
color: #ff8a80;
}
.record-zone-mobile {
width: 100vw;
margin-left: -24px;
gap: 0;
}
.mobile-message-panel {
width: 100vw;
min-width: 0;
@@ -0,0 +1,51 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const frontendPort = 15112;
const apiPort = 15113;
export default defineConfig({
testDir: '.',
testMatch: ['main-records.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/main-records-live'),
use: {
...devices['Desktop Chrome'],
baseURL: `http://127.0.0.1:${frontendPort}/che/`,
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: [
{
command:
`GAME_API_HOST=127.0.0.1 GAME_API_PORT=${apiPort} ` +
'GAME_TRPC_PATH=/che/api/trpc GAME_API_EVENTS_PATH=/che/api/events ' +
'PROFILE=che SCENARIO=default GAME_PROFILE_NAME=che:default node app/game-api/dist/index.js',
cwd: repositoryRoot,
url: `http://127.0.0.1:${apiPort}/che/api/trpc/health.ping?input=%7B%22json%22%3Anull%7D`,
reuseExistingServer: false,
timeout: 120_000,
},
{
command:
`VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc ` +
`VITE_GAME_SSE_URL=http://127.0.0.1:${apiPort}/che/api/events ` +
`pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${frontendPort}/che/`,
reuseExistingServer: false,
timeout: 120_000,
},
],
});
@@ -0,0 +1,356 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Page, type Route } from '@playwright/test';
import {
createGamePostgresConnector,
createRedisConnector,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
type GamePrismaClient,
type RedisConnector,
} from '../../packages/infra/src/index.js';
const RECORD_MARKER = `main-records-live-${randomUUID()}`;
const accessToken = `ga_${randomUUID()}`;
let postgres: ReturnType<typeof createGamePostgresConnector>;
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<HTMLElement>('[data-record-bucket]')];
const mobileZone = document.querySelector<HTMLElement>('.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<HTMLElement>('.record-panel');
const title = panel?.querySelector<HTMLElement>('.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: `<C>●</>${RECORD_MARKER} 장수 동향 ${15 - index}`,
})),
...Array.from({ length: 7 }, (_, index) => ({
scope: 'GENERAL' as const,
category: 'ACTION' as const,
generalId: general.id,
text: `<Y>●</>${RECORD_MARKER} 개인 기록 ${7 - index}`,
})),
...Array.from({ length: 15 }, (_, index) => ({
scope: 'SYSTEM' as const,
category: 'HISTORY' as const,
text: `<R>●</>${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();
});
@@ -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();
}
@@ -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;