Files

358 lines
15 KiB
TypeScript

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 === 'auth.status') return trpcResponse({ userId: 'frontend-parity-user' });
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();
});