358 lines
15 KiB
TypeScript
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();
|
|
});
|