diff --git a/app/game-api/test/nationBettingRouter.integration.test.ts b/app/game-api/test/nationBettingRouter.integration.test.ts index b1f6832..8acbd73 100644 --- a/app/game-api/test/nationBettingRouter.integration.test.ts +++ b/app/game-api/test/nationBettingRouter.integration.test.ts @@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl); const bettingId = 990_071; const concurrentBettingId = 990_072; const generalId = 9_971; +const otherGeneralId = 9_972; const nationId = 990_071; +const otherNationId = 990_072; const userId = 'nation-betting-router-user'; +const otherUserId = 'nation-betting-router-other-user'; +const noGeneralUserId = 'nation-betting-router-no-general-user'; const auth: GameSessionTokenPayload = { version: 1, @@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; +const otherAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'nation-betting-router-other-session', + user: { + ...auth.user, + id: otherUserId, + username: 'other-bettor', + displayName: 'Other Bettor', + }, +}; + +const noGeneralAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'nation-betting-router-no-general-session', + user: { + ...auth.user, + id: noGeneralUserId, + username: 'no-general', + displayName: 'No General', + }, +}; + integration('nation betting router', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; let worldStateId: number; - const buildContext = (requestId: string): GameApiContext => { + const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => { const redisClient = { get: async () => null, set: async () => null, @@ -52,7 +78,7 @@ integration('nation betting router', () => { uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, - auth, + auth: actorAuth, accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', @@ -64,33 +90,55 @@ integration('nation betting router', () => { await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); - await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); - await db.rankData.deleteMany({ where: { generalId } }); - await db.inheritanceLog.deleteMany({ where: { userId } }); - await db.inheritancePoint.deleteMany({ where: { userId } }); - await db.general.deleteMany({ where: { id: generalId } }); - await db.nation.deleteMany({ where: { id: nationId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); - await db.nation.create({ - data: { - id: nationId, - name: '베팅국', - color: '#123456', - level: 2, - }, + await db.nation.createMany({ + data: [ + { + id: nationId, + name: '베팅국', + color: '#123456', + level: 2, + }, + { + id: otherNationId, + name: '다른베팅국', + color: '#654321', + level: 6, + }, + ], }); - await db.general.create({ - data: { - id: generalId, - userId, - name: '베팅장수', - nationId, - cityId: 1, - npcState: 0, - turnTime: new Date('0200-01-01T00:00:00.000Z'), - meta: {}, - }, + await db.general.createMany({ + data: [ + { + id: generalId, + userId, + name: '베팅장수', + nationId, + cityId: 1, + npcState: 0, + officerLevel: 0, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: {}, + }, + { + id: otherGeneralId, + userId: otherUserId, + name: '다른국가수뇌', + nationId: otherNationId, + cityId: 1, + npcState: 0, + officerLevel: 12, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: {}, + }, + ], }); const world = await db.worldState.create({ data: { @@ -132,19 +180,22 @@ integration('nation betting router', () => { candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }], }, }); - await db.inheritancePoint.create({ - data: { userId, key: 'previous', value: 1_000 }, + await db.inheritancePoint.createMany({ + data: [ + { userId, key: 'previous', value: 1_000 }, + { userId: otherUserId, key: 'previous', value: 500 }, + ], }); }); afterAll(async () => { - await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); - await db.rankData.deleteMany({ where: { generalId } }); - await db.inheritanceLog.deleteMany({ where: { userId } }); - await db.inheritancePoint.deleteMany({ where: { userId } }); - await db.general.deleteMany({ where: { id: generalId } }); - await db.nation.deleteMany({ where: { id: nationId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.worldState.delete({ where: { id: worldStateId } }); await closeDb?.(); }); @@ -229,12 +280,107 @@ integration('nation betting router', () => { }), ]); expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']); - expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })) - .toMatchObject({ _sum: { amount: 600 } }); + expect( + await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }) + ).toMatchObject({ _sum: { amount: 600 } }); expect( await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } }, }) ).toMatchObject({ value: 250 }); }); + + it('requires authentication and an owned player general for every betting operation', async () => { + await expect( + appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({ + req: 'bettingNation', + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter + .createCaller(buildContext('nation-betting-anonymous-detail', null)) + .betting.getDetail({ bettingId }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({ + bettingId, + bettingType: [0], + amount: 10, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({ + req: 'bettingNation', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' }); + await expect( + appRouter + .createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth)) + .betting.getDetail({ bettingId }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' }); + await expect( + appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({ + bettingId, + bettingType: [0], + amount: 10, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' }); + }); + + it('allows generals across nation and office levels while isolating each session user bet', async () => { + await expect( + appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({ + req: 'bettingNation', + }) + ).resolves.toMatchObject({ + result: true, + bettingList: { + [bettingId]: { name: '천통국 예상' }, + }, + }); + await expect( + appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({ + bettingId, + bettingType: [0], + amount: 100, + }) + ).resolves.toEqual({ result: true }); + + const [firstUserDetail, otherUserDetail] = await Promise.all([ + appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }), + appRouter + .createCaller(buildContext('nation-betting-other-user-detail', otherAuth)) + .betting.getDetail({ bettingId }), + ]); + expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]); + expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]); + expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]); + expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]); + + expect( + await db.nationBet.findUniqueOrThrow({ + where: { + bettingId_userId_selectionKey: { + bettingId, + userId: otherUserId, + selectionKey: '[0]', + }, + }, + }) + ).toMatchObject({ + generalId: otherGeneralId, + userId: otherUserId, + amount: 100, + }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: otherUserId, key: 'previous' } }, + }) + ).toMatchObject({ value: 400 }); + expect( + await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } }, + }) + ).toMatchObject({ nationId: otherNationId, value: 100 }); + }); }); diff --git a/app/game-frontend/src/views/NationBettingView.vue b/app/game-frontend/src/views/NationBettingView.vue index c776908..c128315 100644 --- a/app/game-frontend/src/views/NationBettingView.vue +++ b/app/game-frontend/src/views/NationBettingView.vue @@ -73,9 +73,7 @@ const listYearMonth = computed(() => { }); const listItems = computed(() => - list.value - ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) - : [] + list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : [] ); const info = computed(() => detail.value?.bettingInfo ?? null); @@ -112,9 +110,7 @@ const detailRows = computed(() => const myBetMap = computed(() => new Map(detail.value?.myBetting ?? [])); -const totalAmount = computed(() => - (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0) -); +const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)); const pureAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce( @@ -137,9 +133,7 @@ const candidateAmounts = computed(() => { return result; }); -const usedAmount = computed(() => - Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0) -); +const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)); const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b))); @@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => { return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.'; }; -const parseYearMonth = (yearMonth: number): [number, number] => [ - Math.floor(yearMonth / 12), - (yearMonth % 12) + 1, -]; +const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1]; const readSelection = (value: string): number[] => { try { @@ -171,8 +162,7 @@ const selectionLabel = (value: string): string => .map((index) => candidates.value[index]?.title ?? '-') .join(', '); -const isListOpen = (item: BettingListItem): boolean => - !item.finished && listYearMonth.value <= item.closeYearMonth; +const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth; const isDetailOpen = computed(() => Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth) @@ -192,19 +182,72 @@ const rowColor = (key: string): string => { return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green'; }; -const expectedMultiplier = (key: string, betAmount: number): string => { - if (betAmount <= 0) { - return '0.0'; +const rewardByMatch = computed(() => { + const selectCount = info.value?.selectCnt ?? 0; + const rewards = new Array(selectCount + 1).fill(0); + if (selectCount <= 0) { + return rewards; } + const amountByMatch = new Map(); + for (const [key, betAmount] of detailRows.value) { + const matched = matchCount(key); + amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount); + } + if (selectCount === 1 || info.value?.isExclusive) { + rewards[selectCount] = totalAmount.value; + return rewards; + } + + let remainingReward = totalAmount.value; + let accumulatedReward = 0; + let nextReward = totalAmount.value; + for (let matched = selectCount; matched > 0; matched -= 1) { + nextReward /= 2; + accumulatedReward += nextReward; + if (!amountByMatch.has(matched)) { + continue; + } + rewards[matched] = accumulatedReward; + remainingReward -= accumulatedReward; + accumulatedReward = 0; + } + for (let matched = selectCount; matched >= 0; matched -= 1) { + if (!amountByMatch.has(matched)) { + continue; + } + rewards[matched] += remainingReward; + break; + } + return rewards; +}); + +const expectedReward = (key: string): number => { if (!info.value?.finished) { - const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2; - return (reward / betAmount).toFixed(1); + return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2; } - const matched = matchCount(key); - const matchedAmount = detailRows.value - .filter(([candidateKey]) => matchCount(candidateKey) === matched) - .reduce((sum, [, value]) => sum + value, 0); - return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0'; + return rewardByMatch.value[matchCount(key)] ?? 0; +}; + +const rewardDivisor = (key: string, betAmount: number): number => + info.value?.finished + ? detailRows.value + .filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key)) + .reduce((sum, [, value]) => sum + value, 0) + : betAmount; + +const expectedMultiplier = (key: string, betAmount: number): string => { + const divisor = rewardDivisor(key, betAmount); + return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0'; +}; + +const myExpectedReward = (key: string, betAmount: number): string => { + const myAmount = myBetMap.value.get(key); + if (myAmount === undefined) { + return ''; + } + const divisor = rewardDivisor(key, betAmount); + const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0; + return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`; }; const loadList = async () => { @@ -295,7 +338,7 @@ onMounted(() => {
돌아가기 - +

국가 베팅장

@@ -309,32 +352,37 @@ onMounted(() => { {{ info.name }} (종료) - ({{ parseYearMonth(info.closeYearMonth)[0] }}년 - {{ parseYearMonth(info.closeYearMonth)[1] }}월까지) + ({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지) (베팅 마감) (총액: {{ totalAmount.toLocaleString('ko-KR') }})
- + +
@@ -361,7 +409,7 @@ onMounted(() => { {{ selectionLabel(key) }}
{{ betAmount.toLocaleString('ko-KR') }}
-
{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}
+
{{ myExpectedReward(key, betAmount) }}
{{ expectedMultiplier(key, betAmount) }}배
@@ -382,8 +430,7 @@ onMounted(() => { {{ item.name }} (종료) - ({{ parseYearMonth(item.closeYearMonth)[0] }}년 - {{ parseYearMonth(item.closeYearMonth)[1] }}월까지) + ({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지) (베팅 마감) @@ -400,12 +447,11 @@ onMounted(() => { .nation-betting-page { position: relative; width: 500px; - min-height: 100vh; margin: 0 auto; color: #fff; font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; font-size: 14px; - line-height: 1.3; + line-height: 1.5; overflow-x: hidden; } @@ -458,19 +504,32 @@ onMounted(() => { } .section-title { - min-height: 22px; + min-height: 21px; text-align: center; - line-height: 22px; + line-height: 21px; } .betting-candidates { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 4px; - padding: 4px; + display: flex; + flex-wrap: wrap; + margin-top: -3.5px; + margin-right: -1.75px; + margin-left: -1.75px; +} + +.betting-candidate-cell { + flex: 0 0 auto; + width: 33.33333333%; + max-width: 100%; + padding-right: 1.75px; + padding-left: 1.75px; + margin-top: 3.5px; } .betting-candidate { + width: 100%; + height: 100%; + min-height: 143px; min-width: 0; padding: 0; border: 1px solid gray; @@ -530,7 +589,7 @@ onMounted(() => { .betting-form input { grid-column: span 4; min-width: 0; - height: 30px; + height: 35.5px; border: 1px solid #777; background: #ddd; color: #303030; @@ -538,10 +597,11 @@ onMounted(() => { .betting-form button { grid-column: span 2; + height: 35.5px; } .payout-table { - margin-top: 6px; + margin-top: 0; } .payout-row { @@ -551,7 +611,7 @@ onMounted(() => { .payout-row > div { min-width: 0; - padding: 2px 4px; + padding: 0 4px; } .payout-row > div:not(:first-child) { @@ -572,7 +632,7 @@ onMounted(() => { .betting-item { display: block; - width: 100%; + width: auto; margin: 0.25em; border: 0; background: transparent; @@ -595,6 +655,7 @@ onMounted(() => { .betting-footer .legacy-nav-button { width: 90px; + height: 35.5px; } .betting-notice, @@ -602,6 +663,15 @@ onMounted(() => { padding: 6px 10px; } +.betting-notice { + position: fixed; + top: 8px; + right: 8px; + z-index: 20; + width: min(320px, calc(100vw - 16px)); + background: #303030; +} + .betting-notice.error { border: 1px solid #9b4848; color: #ffd0d0; @@ -617,8 +687,9 @@ onMounted(() => { width: 1000px; } - .betting-candidates { - grid-template-columns: repeat(6, minmax(0, 1fr)); + .betting-candidate-cell { + /* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */ + width: 16.66666667%; } .betting-form { diff --git a/tools/frontend-legacy-parity/nation-betting-ref-measure.mjs b/tools/frontend-legacy-parity/nation-betting-ref-measure.mjs new file mode 100644 index 0000000..4a951d6 --- /dev/null +++ b/tools/frontend-legacy-parity/nation-betting-ref-measure.mjs @@ -0,0 +1,237 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { chromium } from '@playwright/test'; + +const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/'; +const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL; +const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1'; +const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting'); + +if (!staticBaseUrl && !passwordFile) { + throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.'); +} + +const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : ''; + +const bettingList = { + result: true, + bettingList: { + 7: { + id: 7, + type: 'bettingNation', + name: '천통국 예상', + finished: false, + selectCnt: 2, + isExclusive: false, + reqInheritancePoint: true, + openYearMonth: 2316, + closeYearMonth: 2340, + winner: null, + totalAmount: 800, + }, + }, + year: 193, + month: 1, +}; + +const bettingDetail = { + result: true, + bettingInfo: { + id: 7, + type: 'bettingNation', + name: '천통국 예상', + finished: false, + selectCnt: 2, + isExclusive: false, + reqInheritancePoint: true, + openYearMonth: 2316, + closeYearMonth: 2340, + candidates: [ + { title: '촉', info: '국력: 1200
장수 수: 8
도시 수: 5', isHtml: true }, + { title: '위', info: '국력: 1100
장수 수: 7
도시 수: 4', isHtml: true }, + { title: '오', info: '국력: 900
장수 수: 6
도시 수: 3', isHtml: true }, + { title: '연', info: '국력: 700
장수 수: 5
도시 수: 2', isHtml: true }, + { title: '양', info: '국력: 650
장수 수: 4
도시 수: 2', isHtml: true }, + { title: '형', info: '국력: 600
장수 수: 3
도시 수: 1', isHtml: true }, + ], + winner: null, + }, + bettingDetail: [ + ['[-1]', 500], + ['[0,1]', 200], + ['[1,2]', 100], + ], + myBetting: [['[0,1]', 50]], + remainPoint: 1200, + year: 193, + month: 1, +}; + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + // The reference entrance polls install status and can keep the PHP session + // occupied. Leave it before the login request so the session lock is free. + await page.goto('about:blank'); + await context.clearCookies(); + 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 }, + timeout: 60_000, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const installBettingFixture = async (page) => { + await page.route('**/api.php*', async (route) => { + const path = new URL(route.request().url()).searchParams.get('path'); + if (path === 'Betting/GetBettingList') { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) }); + return; + } + if (path === 'Betting/GetBettingDetail') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(bettingDetail), + }); + return; + } + await route.continue(); + }); +}; + +const mountStaticReference = async (page) => { + const hweUrl = new URL('hwe/', staticBaseUrl); + const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl); + await page.setContent( + ` + + + + + + + + + + + + +
+ + + + + + + `, + { waitUntil: 'networkidle' } + ); +}; + +const measure = async (browser, viewport) => { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + colorScheme: 'dark', + locale: 'ko-KR', + timezoneId: 'UTC', + ignoreHTTPSErrors: true, + }); + try { + const page = await context.newPage(); + await installBettingFixture(page); + if (staticBaseUrl) { + await mountStaticReference(page); + } else { + await login(context, page); + await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + } + await page.locator('.bettingItem').click(); + await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' }); + + const geometry = await page.locator('#container').evaluate((container) => { + const rect = (element) => { + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const cards = Array.from(container.querySelectorAll('.bettingCandidate')); + const firstCard = cards[0]; + const cardStyle = getComputedStyle(firstCard); + const titleStyle = getComputedStyle(firstCard.querySelector('.title')); + const optionalRect = (selector) => { + const element = container.querySelector(selector); + return element ? rect(element) : null; + }; + return { + container: rect(container), + topBar: rect(container.querySelector('.back_bar')), + candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect), + candidates: cards.map(rect), + bettingForm: optionalRect('.bettingCandidates + .row'), + payoutTable: optionalRect('.bettingCandidates + .row + div'), + bettingList: optionalRect('.bettingList'), + bottomBar: optionalRect('.bottom_bar, .bg0[style]'), + cardStyle: { + borderWidth: cardStyle.borderWidth, + borderRadius: cardStyle.borderRadius, + cursor: cardStyle.cursor, + fontSize: cardStyle.fontSize, + lineHeight: cardStyle.lineHeight, + }, + titleStyle: { + fontWeight: titleStyle.fontWeight, + textAlign: titleStyle.textAlign, + }, + }; + }); + + await page.locator('.bettingCandidate').first().click(); + const pickedStyle = await page + .locator('.bettingCandidate') + .first() + .evaluate((candidate) => { + const style = getComputedStyle(candidate); + return { + borderColor: style.borderColor, + outlineWidth: style.outlineWidth, + titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight, + }; + }); + + const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`); + await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' }); + return { geometry, pickedStyle, screenshotPath }; + } finally { + await context.close(); + } +}; + +await mkdir(artifactRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const result = {}; + for (const viewport of [ + { name: 'desktop', width: 1280, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + result[viewport.name] = await measure(browser, viewport); + } + const outputPath = resolve(artifactRoot, 'computed-dom.json'); + await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`); +} finally { + await browser.close(); +} diff --git a/tools/frontend-legacy-parity/public-gaps.spec.ts b/tools/frontend-legacy-parity/public-gaps.spec.ts index 0977f8d..db128e9 100644 --- a/tools/frontend-legacy-parity/public-gaps.spec.ts +++ b/tools/frontend-legacy-parity/public-gaps.spec.ts @@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const imageRoots = [ - resolve(repositoryRoot, '../image/game'), - resolve(repositoryRoot, '../../image/game'), -]; +const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const response = (data: unknown) => ({ result: { data } }); @@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed const geometry = await page.locator('#nation-betting-container').evaluate((container) => { const containerRect = container.getBoundingClientRect(); const bar = container.querySelector('.legacy-top-bar')!.getBoundingClientRect(); + const detail = container.querySelector('.betting-detail')!; + const detailRect = detail.getBoundingClientRect(); + const candidateRowElement = container.querySelector('.betting-candidates')!; + const candidateRow = candidateRowElement.getBoundingClientRect(); + const candidateCells = Array.from(container.querySelectorAll('.betting-candidate-cell')); const cards = Array.from(container.querySelectorAll('.betting-candidate')); const cardStyle = getComputedStyle(cards[0]!); + const optionalRect = (selector: string) => { + const element = container.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; return { - container: { x: containerRect.x, width: containerRect.width }, + container: { x: containerRect.x, width: containerRect.width, height: containerRect.height }, bar: { width: bar.width, height: bar.height }, - cardWidths: cards.map((card) => card.getBoundingClientRect().width), + detail: { x: detailRect.x, width: detailRect.width }, + candidateRow: { + x: candidateRow.x, + width: candidateRow.width, + }, + candidateCells: candidateCells.map((cell) => { + const rect = cell.getBoundingClientRect(); + return { x: rect.x, width: rect.width }; + }), + cards: cards.map((card) => { + const rect = card.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + bettingForm: optionalRect('.betting-form'), + payoutTable: optionalRect('.payout-table'), + bettingList: optionalRect('.betting-list'), + bottomBar: optionalRect('.betting-footer'), cardStyle: { borderWidth: cardStyle.borderWidth, borderRadius: cardStyle.borderRadius, @@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed }, }; }); - - expect(geometry.container).toEqual({ x: 140, width: 1000 }); + expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 }); expect(geometry.bar).toEqual({ width: 1000, height: 32 }); - expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true); + expect(geometry.detail).toEqual({ x: 140, width: 1000 }); + expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 }); + expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25)); + expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75)); + expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143)); + expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53)); + expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 }); + expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 }); + expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 }); + expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 }); expect(geometry.cardStyle).toEqual({ borderWidth: '1px', borderRadius: '7px', cursor: 'pointer', fontSize: '14px', - lineHeight: '18.2px', + lineHeight: '21px', }); + await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1); + await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText( + '(50 -> 100.0)' + ); await page.locator('.betting-candidate').nth(0).click(); await page.locator('.betting-candidate').nth(1).click(); - const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => { - const style = getComputedStyle(candidate); - return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight }; - }); + const pickedStyle = await page + .locator('.betting-candidate') + .first() + .evaluate((candidate) => { + const style = getComputedStyle(candidate); + return { + borderColor: style.borderColor, + outlineWidth: style.outlineWidth, + titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight, + }; + }); expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)'); // Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1. expect(pickedStyle.outlineWidth).toBe('1px'); @@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async return { x: rect.x, width: rect.width, + firstX: cards[0]!.getBoundingClientRect().x, firstWidth: cards[0]!.getBoundingClientRect().width, fourthY: cards[3]!.getBoundingClientRect().y, firstY: cards[0]!.getBoundingClientRect().y, @@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async }); expect(geometry.x).toBe(0); expect(geometry.width).toBe(500); - expect(geometry.firstWidth).toBeCloseTo(161.328125, 3); + expect(geometry.firstX).toBe(0); + expect(geometry.firstWidth).toBe(164.328125); expect(geometry.fourthY).toBeGreaterThan(geometry.firstY); if (artifactRoot) { @@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention', expect(geometry.tableWidth).toBe(1000); // The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table. expect(geometry.headerWidths).toEqual([ - 104.609375, - 104.609375, - 69.734375, - 121.015625, - 69.734375, - 90.25, - 69.734375, - 69.734375, - 69.734375, - 69.734375, - 80, + 104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80, 80.109375, ]); expect(geometry.headerStyle).toEqual({ @@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention', lineHeight: '18.2px', }); await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우'); - await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)'); + await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS( + 'color', + 'rgb(135, 206, 235)' + ); const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first(); await personality.hover(); await expect(personality.getByRole('tooltip')).toBeVisible();