diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index e73f240..b170a83 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -2,8 +2,11 @@ import { z } from 'zod'; import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js'; +import type { ItemModule } from '@sammo-ts/logic/items/types.js'; +import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; +import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; -import { procedure, router } from '../../trpc.js'; +import { authedProcedure, procedure, router } from '../../trpc.js'; const DEFAULT_BG_COLOR = '#2b2b2b'; const DEFAULT_FG_COLOR = '#ffffff'; @@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => { const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`; +const readOwnerDisplayName = (value: unknown): string | null => { + const meta = asRecord(value); + if (typeof meta.ownerName === 'string' && meta.ownerName.length > 0) { + return meta.ownerName; + } + if (typeof meta.owner_name === 'string' && meta.owner_name.length > 0) { + return meta.owner_name; + } + return null; +}; + const itemLoader = new ItemLoader(); -let cachedUniqueItems: Promise< - Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }> -> | null = null; +let cachedUniqueItems: Promise | null = null; const loadUniqueItems = () => { if (!cachedUniqueItems) { cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) => - modules - .filter((module) => module.unique && !module.buyable) - .map((module) => ({ - key: module.key, - name: module.name, - slot: module.slot, - unique: module.unique, - buyable: module.buyable, - info: module.info, - })) + modules.filter((module) => module.unique && !module.buyable) ); } return cachedUniqueItems; }; export const rankingRouter = router({ - getBestGeneral: procedure + getBestGeneral: authedProcedure .input( z .object({ @@ -57,7 +60,7 @@ export const rankingRouter = router({ ) .query(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst({ - select: { meta: true }, + select: { meta: true, config: true }, }); const meta = asRecord(worldState?.meta); const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; @@ -76,6 +79,7 @@ export const rankingRouter = router({ userId: true, picture: true, imageServer: true, + meta: true, experience: true, dedication: true, horseCode: true, @@ -185,7 +189,7 @@ export const rankingRouter = router({ let display = { id: general.id, name: general.name, - ownerName: general.userId ?? null, + ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, nationName: nation?.name ?? '재야', bgColor: nation?.color ?? DEFAULT_BG_COLOR, fgColor: DEFAULT_FG_COLOR, @@ -217,46 +221,91 @@ export const rankingRouter = router({ }); const uniqueItems = await loadUniqueItems(); - const itemEntries = uniqueItems.map((item) => { - const owners = generals.filter((general) => { - if (item.slot === 'horse') { - return general.horseCode === item.key; + const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item])); + const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const)); + if (Object.keys(uniqueConfig.allItems).length === 0) { + uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); + } + const activeAuctions = await ctx.db.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + const auctionCounts = new Map(); + for (const auction of activeAuctions) { + if (auction.targetCode) { + auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1); + } + } + const slotTitles = { + horse: '명 마', + weapon: '명 검', + book: '명 서', + item: '도 구', + } as const; + const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { + const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); + const entries = configuredItems.flatMap(([itemKey, rawCount]) => { + const item = itemRegistry.get(itemKey); + if (!item || item.buyable) { + return []; } - if (item.slot === 'weapon') { - return general.weaponCode === item.key; + const owners = generals + .filter((general) => { + if (slot === 'horse') { + return general.horseCode === itemKey; + } + if (slot === 'weapon') { + return general.weaponCode === itemKey; + } + if (slot === 'book') { + return general.bookCode === itemKey; + } + return general.itemCode === itemKey; + }) + .map((general) => { + const nation = nationMap.get(general.nationId) ?? null; + return { + id: general.id, + name: general.name, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + }; + }); + for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) { + owners.push({ + id: 0, + name: '경매중', + nationName: '-', + bgColor: '#00582c', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + }); } - if (item.slot === 'book') { - return general.bookCode === item.key; - } - return general.itemCode === item.key; + const count = Math.max(0, Math.floor(rawCount)); + return Array.from({ length: count }, (_, index) => ({ + itemKey, + itemName: item.name, + itemInfo: item.info, + owner: owners[index] ?? { + id: 0, + name: '미발견', + nationName: '-', + bgColor: DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: null, + imageServer: 0, + }, + })); }); - - const displayOwners = owners.length - ? owners.map((general) => { - const nation = nationMap.get(general.nationId) ?? null; - return { - id: general.id, - name: general.name, - nationName: nation?.name ?? '재야', - bgColor: nation?.color ?? DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, - }; - }) - : [ - { - id: 0, - name: '미발견', - nationName: '-', - bgColor: DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, - }, - ]; - - return { - title: item.name, - slot: item.slot, - owners: displayOwners, - }; + return { title: slotTitles[slot], slot, entries }; }); return { @@ -339,6 +388,10 @@ export const rankingRouter = router({ return { generalId: row.generalNo, name: String(aux.name ?? ''), + ownerName: + typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 + ? aux.ownerDisplayName + : null, nationName: String(aux.nationName ?? ''), bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts new file mode 100644 index 0000000..7f897a6 --- /dev/null +++ b/app/game-api/test/rankingRouter.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'ranking-session', + user: { + id: 'request-user-id', + username: 'ranking-user', + displayName: '조회자', + roles: [], + }, + sanctions: {}, +}; + +const generalRows = [ + { + id: 1, + name: '유비', + nationId: 1, + userId: 'private-user-id-1', + npcState: 0, + picture: '1.jpg', + imageServer: 0, + meta: { ownerName: '공개소유자' }, + experience: 1200, + dedication: 900, + horseCode: 'che_명마_15_적토마', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, + { + id: 2, + name: '빙의관우', + nationId: 1, + userId: 'private-user-id-2', + npcState: 1, + picture: null, + imageServer: 0, + meta: { owner_name: '빙의소유자' }, + experience: 1100, + dedication: 800, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, + { + id: 3, + name: 'NPC조조', + nationId: 2, + userId: null, + npcState: 2, + picture: null, + imageServer: 0, + meta: {}, + experience: 1300, + dedication: 1000, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, +] as const; + +const buildContext = (options?: { + authenticated?: boolean; + isUnited?: boolean; + includeOwnerDisplayName?: boolean; +}): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + meta: { isUnited: options?.isUnited ? 1 : 0 }, + config: { + const: { + allItems: { + horse: { che_명마_15_적토마: 2 }, + weapon: {}, + book: {}, + item: {}, + }, + }, + }, + }), + }, + nation: { + findMany: async () => [ + { id: 1, name: '촉', color: '#006400' }, + { id: 2, name: '위', color: '#8b0000' }, + ], + }, + general: { + findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) => + generalRows.filter((general) => + args.where.npcState.gte !== undefined + ? general.npcState >= args.where.npcState.gte + : general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY) + ), + }, + rankData: { + findMany: async () => [ + { generalId: 1, type: 'firenum', value: 10 }, + { generalId: 2, type: 'firenum', value: 20 }, + { generalId: 3, type: 'firenum', value: 30 }, + ], + }, + auction: { + findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], + }, + gameHistory: { + findMany: async () => [ + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + ], + }, + hallOfFame: { + findMany: async (args: { where: { type: string } }) => + args.where.type === 'experience' + ? [ + { + generalNo: 1, + value: 1200, + aux: { + name: '유비', + ownerName: 'private-hall-user-id', + ...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}), + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + }, + }, + ] + : [], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: options?.authenticated === false ? null : auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('ranking.getBestGeneral', () => { + it('requires a game login even though the ranking is the same for every authenticated user', async () => { + await expect( + appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => { + const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]); + expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([ + expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }), + expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }), + ]); + expect(JSON.stringify(result)).not.toContain('private-user-id'); + }); + + it('uses display names only after unification and preserves configured item copies plus auctions', async () => { + const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']); + expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([ + expect.objectContaining({ + itemKey: 'che_명마_15_적토마', + owner: expect.objectContaining({ id: 1, name: '유비' }), + }), + expect.objectContaining({ + itemKey: 'che_명마_15_적토마', + owner: expect.objectContaining({ id: 0, name: '경매중' }), + }), + ]); + expect(JSON.stringify(result)).not.toContain('private-user-id'); + }); + + it('separates autonomous NPCs from users and possessed generals', async () => { + const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' }); + expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]); + }); +}); + +describe('ranking hall of fame', () => { + it('remains public and groups scenario counts', async () => { + const options = await appRouter + .createCaller(buildContext({ authenticated: false })) + .ranking.getHallOfFameOptions(); + expect(options).toEqual([ + { + season: 3, + scenarios: [{ id: 22, name: '가상모드22', count: 2 }], + }, + ]); + }); + + it('returns an explicit display name but never exposes the stored account identifier', async () => { + const result = await appRouter + .createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true })) + .ranking.getHallOfFame({ season: 3 }); + expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자'); + expect(JSON.stringify(result)).not.toContain('private-hall-user-id'); + + const redacted = await appRouter + .createCaller(buildContext({ authenticated: false })) + .ranking.getHallOfFame({ season: 3 }); + expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull(); + }); +}); diff --git a/app/game-frontend/src/views/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue index 63e2add..cf4345b 100644 --- a/app/game-frontend/src/views/BestGeneralView.vue +++ b/app/game-frontend/src/views/BestGeneralView.vue @@ -1,5 +1,7 @@ + + diff --git a/app/game-frontend/src/views/HallOfFameView.vue b/app/game-frontend/src/views/HallOfFameView.vue index 3ea0afa..f112db3 100644 --- a/app/game-frontend/src/views/HallOfFameView.vue +++ b/app/game-frontend/src/views/HallOfFameView.vue @@ -135,7 +135,7 @@ onMounted(async () => { -
{{ errorMessage }}
+
불러오는 중...
표시할 데이터가 없습니다.
@@ -171,6 +171,10 @@ onMounted(async () => {
+
+ 삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / + Credit +
@@ -191,7 +195,7 @@ onMounted(async () => { .legacy-hall-title, .legacy-hall-bottom { - text-align: center; + text-align: left; } .legacy-hall-title { @@ -205,12 +209,33 @@ onMounted(async () => { } .scenario-search select { - min-width: 220px; + width: 189px; + height: 20px; border: 1px solid #555; background: #ddd; color: #303030; } +.legacy-button { + border: 0; + border-radius: 5.25px; + background: #375a7f; + padding: 5.25px 10.5px; + font-weight: 700; + line-height: 21px; +} + +.legacy-button:hover, +.legacy-button:focus, +.legacy-button:active { + background: #6b6b6b; +} + +.legacy-button:focus-visible { + outline: revert; + outline-offset: 0; +} + .legacy-message { border: 1px solid gray; padding: 12px; @@ -221,6 +246,15 @@ onMounted(async () => { color: #ff6b6b; } +.legacy-banner { + font-size: 13px; +} + +.legacy-banner a { + color: #fff; + text-decoration: underline; +} + .hall-sections { display: block; } @@ -235,7 +269,9 @@ onMounted(async () => { margin: 0; border-bottom: 1px solid gray; padding: 2px; - font-size: 1.17em; + font-size: calc(19px + 0.784615vw); + font-weight: 500; + line-height: 1.2; text-align: center; } @@ -275,7 +311,7 @@ onMounted(async () => { display: inline-block; width: 64px; height: 64px; - object-fit: cover; + object-fit: fill; } .hall-server, @@ -309,5 +345,9 @@ onMounted(async () => { .legacy-hall-page { width: 1000px; } + + .rankType { + font-size: 28px; + } } diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 39beb36..b15d1a5 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -19,6 +19,8 @@ tree instead of replacing images with layout-neutral placeholders. NPC list, including mutations and recoverable API failures. `tournament-betting.spec.ts` covers the separate tournament and tournament betting routes, including a recoverable failed bet. +`reference-rankings.mjs` records the authenticated PHP 명장일람 and public +명예의 전당 computed DOM without embedding the reference password. Run the suite from the core2026 repository root: @@ -51,7 +53,8 @@ storage, route guards, and image loading. | game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | | troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | | current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation | -| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | +| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | +| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | | inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | | nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | @@ -88,6 +91,16 @@ Adding or changing a frontend route requires: Pixel snapshots may be added after these structural assertions pass. Dynamic regions must not be hidden merely to make a pixel threshold pass. +To refresh the PHP ranking evidence after building the ignored reference +webpack assets, run: + +```sh +REF_RANKING_URL=http://127.0.0.1:3400/sam/ \ +REF_RANKING_PASSWORD_FILE=/path/to/ignored/user1_password \ +REF_RANKING_ARTIFACT_DIR=/path/to/ignored/artifacts \ +node tools/frontend-legacy-parity/reference-rankings.mjs +``` + The nation office suite can be run independently: ```sh diff --git a/tools/frontend-legacy-parity/fixtures/canonical.ts b/tools/frontend-legacy-parity/fixtures/canonical.ts index 8d7d5cf..fd14ef8 100644 --- a/tools/frontend-legacy-parity/fixtures/canonical.ts +++ b/tools/frontend-legacy-parity/fixtures/canonical.ts @@ -75,6 +75,82 @@ export const canonicalFrontendFixture = { [2, '진', '#1976d2', 2], ], }, + bestGeneral: { + isUnited: true, + sections: [ + { + title: '명 성', + valueType: 'int', + entries: [ + { + id: 1, + name: '유비', + ownerName: '시각검증', + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + value: 12000, + printValue: '12,000', + }, + { + id: 2, + name: '조조', + ownerName: '검증계정', + nationName: '위', + bgColor: '#8b0000', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + value: 11000, + printValue: '11,000', + }, + ], + }, + { + title: '계 급', + valueType: 'int', + entries: [], + }, + ], + uniqueItems: [ + { + title: '명 마', + slot: 'horse', + entries: [ + { + itemKey: 'che_명마_15_적토마', + itemName: '적토마', + itemInfo: '최고의 명마', + owner: { + id: 1, + name: '유비', + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + }, + }, + { + itemKey: 'che_명마_15_적토마', + itemName: '적토마', + itemInfo: '최고의 명마', + owner: { + id: 0, + name: '경매중', + nationName: '-', + bgColor: '#00582c', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + }, + }, + ], + }, + ], + }, hallOptions: [ { season: 1, diff --git a/tools/frontend-legacy-parity/reference-rankings.mjs b/tools/frontend-legacy-parity/reference-rankings.mjs new file mode 100644 index 0000000..36e1a12 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-rankings.mjs @@ -0,0 +1,155 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_RANKING_URL ?? 'https://dev-sam-ref.hided.net/sam/'; +const username = process.env.REF_RANKING_USER ?? 'refuser1'; +const passwordFile = process.env.REF_RANKING_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_RANKING_ARTIFACT_DIR ?? 'test-results/reference-rankings'); + +if (!passwordFile) { + throw new Error('REF_RANKING_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + 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 }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const measureRanking = async (page) => + page.evaluate(() => { + const pick = (selector) => { + const element = document.querySelector(selector); + if (!element) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + backgroundImage: style.backgroundImage, + backgroundColor: style.backgroundColor, + color: style.color, + borderTopColor: style.borderTopColor, + borderTopWidth: style.borderTopWidth, + borderRadius: style.borderRadius, + padding: style.padding, + fontWeight: style.fontWeight, + cursor: style.cursor, + minHeight: style.minHeight, + objectFit: style.objectFit, + }, + }; + }; + const image = document.querySelector('.generalIcon'); + return { + title: document.title, + container: pick('#container'), + rankType: pick('.rankType'), + rankCell: pick('.rankView li'), + uniqueCell: pick('.rankView li.no_value'), + image: image + ? { + ...pick('.generalIcon'), + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + } + : null, + firstButton: pick('button, input[type="submit"], input[type="button"]'), + rankSectionCount: document.querySelectorAll('.rankView').length, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + +const browser = await chromium.launch({ headless: true }); +try { + const result = {}; + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768 }, + { name: 'mobile', width: 390, height: 844 }, + ]) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'UTC', + colorScheme: 'dark', + ignoreHTTPSErrors: true, + }); + const page = await context.newPage(); + await login(context, page); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle', timeout: 60_000 }); + + await page.goto(new URL('hwe/a_bestGeneral.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + await page.locator('#container').waitFor(); + const bestGeneral = await measureRanking(page); + const userButton = page.getByRole('button', { name: '유저 보기' }); + await userButton.hover(); + bestGeneral.userButtonHover = await userButton.evaluate((element) => { + const style = getComputedStyle(element); + return { backgroundColor: style.backgroundColor, cursor: style.cursor }; + }); + await userButton.focus(); + bestGeneral.userButtonFocus = await userButton.evaluate((element) => getComputedStyle(element).outline); + await page.screenshot({ + path: resolve(artifactRoot, `ref-best-general-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + + await page.goto(new URL('hwe/a_hallOfFame.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + await page.locator('#container').waitFor(); + const hallOfFame = await measureRanking(page); + const scenario = page.locator('#by_scenario'); + hallOfFame.scenario = await scenario.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { width: rect.width, height: rect.height }, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + }; + }); + await scenario.focus(); + hallOfFame.scenarioFocus = await scenario.evaluate((element) => getComputedStyle(element).outline); + await page.screenshot({ + path: resolve(artifactRoot, `ref-hall-of-fame-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + + result[viewport.name] = { bestGeneral, hallOfFame }; + await context.close(); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`); +} finally { + await browser.close(); +} diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index 85bba67..899a70a 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -139,6 +139,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise => { }; } if (operation === 'public.getMapLayout') return fixture.game.mapLayout; + if (operation === 'ranking.getBestGeneral') return fixture.game.bestGeneral; if (operation === 'yearbook.getRange') return fixture.game.yearbookRange; if (operation === 'yearbook.getHistory') return fixture.game.yearbook; if (operation === 'vote.getVoteList') return fixture.game.surveyList; @@ -387,6 +388,117 @@ test.describe('gateway legacy parity', () => { }); }); +test.describe('best general legacy parity', () => { + test.beforeEach(async ({ page }) => { + await installAuthenticatedGameFixture(page); + }); + + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768, expectedWidth: 1000 }, + { name: 'mobile', width: 390, height: 844, expectedWidth: 500 }, + ]) { + test(`matches the ref fixed ranking grid on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto('http://127.0.0.1:15102/che/best-general'); + await expect(page.getByText('유비').first()).toBeVisible(); + await expect(page.locator('.rankView')).toHaveCount(3); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, `best-general-core-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.evaluate(() => { + const container = document.querySelector('#best-general-container')!; + const item = document.querySelector('.rankView li')!; + const uniqueItem = document.querySelector('.rankView li.no-value')!; + const title = document.querySelector('.rankType')!; + const image = document.querySelector('.generalIcon')!; + return { + container: { + x: container.getBoundingClientRect().x, + width: container.getBoundingClientRect().width, + fontFamily: getComputedStyle(container).fontFamily, + fontSize: getComputedStyle(container).fontSize, + backgroundImage: getComputedStyle(container).backgroundImage, + }, + item: { + width: item.getBoundingClientRect().width, + minHeight: getComputedStyle(item).minHeight, + }, + uniqueItem: { + minHeight: getComputedStyle(uniqueItem).minHeight, + }, + title: { + fontSize: getComputedStyle(title).fontSize, + lineHeight: getComputedStyle(title).lineHeight, + backgroundImage: getComputedStyle(title).backgroundImage, + }, + image: { + width: image.getBoundingClientRect().width, + height: image.getBoundingClientRect().height, + naturalWidth: image.naturalWidth, + objectFit: getComputedStyle(image).objectFit, + }, + closeX: document + .querySelector('.legacy-ranking-title .legacy-button')! + .getBoundingClientRect().x, + }; + }); + + expect(geometry.container.width).toBe(viewport.expectedWidth); + expect(geometry.container.fontFamily).toContain('Pretendard'); + expect(geometry.container.fontSize).toBe('14px'); + expect(geometry.container.backgroundImage).toContain('back_walnut.jpg'); + expect(geometry.closeX).toBe(geometry.container.x); + expect(geometry.item).toEqual({ width: 100, minHeight: '149px' }); + expect(geometry.uniqueItem.minHeight).toBe('128px'); + expect(geometry.title).toMatchObject({ + fontSize: viewport.name === 'desktop' ? '28px' : '22.06px', + lineHeight: viewport.name === 'desktop' ? '33.6px' : '26.472px', + }); + expect(geometry.title.backgroundImage).toContain('back_green.jpg'); + expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'fill' }); + expect(geometry.image.naturalWidth).toBeGreaterThan(0); + + const npcButton = page.getByRole('button', { name: 'NPC 보기' }); + await npcButton.hover(); + await expect(npcButton).toHaveCSS('background-color', 'rgb(107, 107, 107)'); + await npcButton.focus(); + await expect(npcButton).toBeFocused(); + await npcButton.click(); + await expect(npcButton).toHaveAttribute('aria-pressed', 'true'); + + const itemName = page.locator('.item-name').first(); + await itemName.hover(); + await expect(itemName).toHaveAttribute('title', '최고의 명마'); + }); + } + + test('keeps the current ranking and selected user type after an API error', async ({ page }) => { + await page.goto('http://127.0.0.1:15102/che/best-general'); + await expect(page.getByText('유비').first()).toBeVisible(); + await page.route('**/che/api/trpc/**', async (route) => { + if (operationNames(route).includes('ranking.getBestGeneral')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '명장일람 조회에 실패했습니다.' } }), + }); + return; + } + await route.fallback(); + }); + const npcButton = page.getByRole('button', { name: 'NPC 보기' }); + await npcButton.click(); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByText('유비').first()).toBeVisible(); + await expect(npcButton).toHaveAttribute('aria-pressed', 'true'); + }); +}); + test.describe('hall of fame legacy parity', () => { test.beforeEach(async ({ page }) => { await installHallFixture(page); @@ -401,6 +513,13 @@ test.describe('hall of fame legacy parity', () => { await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); await expect(page.getByText('유비')).toBeVisible(); await expect(page.locator('.rankView')).toHaveCount(2); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, `hall-of-fame-core-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } const geometry = await page.evaluate(() => { const container = document.querySelector('#container')!; @@ -409,7 +528,10 @@ test.describe('hall of fame legacy parity', () => { const titleStyle = getComputedStyle(document.querySelector('.rankType')!); const image = document.querySelector('.generalIcon')!; return { - container: container.getBoundingClientRect().width, + container: { + x: container.getBoundingClientRect().x, + width: container.getBoundingClientRect().width, + }, containerBackgroundImage: getComputedStyle(container).backgroundImage, item: { width: item.getBoundingClientRect().width, @@ -427,23 +549,57 @@ test.describe('hall of fame legacy parity', () => { naturalHeight: image.naturalHeight, objectFit: getComputedStyle(image).objectFit, }, + closeX: document + .querySelector('.legacy-hall-title .legacy-button')! + .getBoundingClientRect().x, }; }); - expect(geometry.container).toBe(viewport.expectedWidth); + expect(geometry.container.width).toBe(viewport.expectedWidth); + expect(geometry.closeX).toBe(geometry.container.x); expect(geometry.containerBackgroundImage).toContain('back_walnut.jpg'); expect(geometry.item.width).toBe(100); expect(geometry.title.fontFamily).toContain('Pretendard'); + expect(geometry.title.fontSize).toBe(viewport.name === 'desktop' ? '28px' : '22.06px'); expect(geometry.title.backgroundImage).toContain('back_green.jpg'); - expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'cover' }); + expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'fill' }); expect(geometry.image.naturalWidth).toBeGreaterThan(0); const close = page.getByRole('button', { name: '창 닫기' }).first(); await close.hover(); + await expect(close).toHaveCSS('background-color', 'rgb(107, 107, 107)'); await close.focus(); await expect(close).toBeFocused(); + + const scenario = page.getByLabel('시나리오 검색'); + await expect(scenario).toHaveCSS('width', '189px'); + await scenario.focus(); + await expect(scenario).toBeFocused(); + await scenario.selectOption('scenario:1:22'); + await expect(scenario).toHaveValue('scenario:1:22'); }); } + + test('keeps the selected scenario after a hall API error', async ({ page }) => { + await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); + await expect(page.getByText('유비')).toBeVisible(); + await page.route('**/che/api/trpc/**', async (route) => { + if (operationNames(route).includes('ranking.getHallOfFame')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '명예의 전당 조회에 실패했습니다.' } }), + }); + return; + } + await route.fallback(); + }); + const scenario = page.getByLabel('시나리오 검색'); + await scenario.selectOption('scenario:1:22'); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(scenario).toHaveValue('scenario:1:22'); + await expect(page.getByText('유비')).toBeVisible(); + }); }); test('game login delegates to the gateway like the ref entry point', async ({ page }) => {