diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 36b82562..75b97c7f 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -175,13 +175,14 @@ export const worldRouter = router({ const turns = generalIds.length ? await ctx.db.generalTurn.findMany({ where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }) : []; - const turnMap = new Map(); + const turnMap = new Map>(); for (const turn of turns) { const list = turnMap.get(turn.generalId) ?? []; - list[turn.turnIdx] = turn.actionCode; + list[turn.turnIdx] = { action: turn.actionCode, args: turn.arg }; turnMap.set(turn.generalId, list); } const nationMap = new Map(nations.map((item) => [item.id, item])); diff --git a/app/game-api/test/worldCurrentCityRouter.test.ts b/app/game-api/test/worldCurrentCityRouter.test.ts new file mode 100644 index 00000000..36b84234 --- /dev/null +++ b/app/game-api/test/worldCurrentCityRouter.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } 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 type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +vi.mock('../src/maps/mapLayout.js', () => ({ + loadMapLayout: vi.fn(async () => ({ + mapName: 'che', + cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 0, y: 0, path: [] }], + regionMap: { 1: '하북' }, + levelMap: { 8: '특' }, + })), +})); + +vi.mock('@sammo-ts/game-engine/scenario/unitSetLoader.js', () => ({ + loadUnitSetDefinitionByName: vi.fn(async () => ({ crewTypes: [{ id: 1, name: '보병' }] })), +})); + +const now = new Date('2026-01-01T01:02:00Z'); +const general = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'u1', + name: '장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 900, + dedication: 100, + officerLevel: 1, + gold: 1000, + rice: 2000, + crew: 300, + crewTypeId: 1, + train: 90, + atmos: 90, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { defence_train: 80 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const token = (): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: 'session-1', + user: { id: 'u1', username: 'u1', displayName: '장수', roles: [] }, + sanctions: {}, +}); + +const fixture = (authenticated = true) => { + const actor = general(); + const npc = general({ id: 2, userId: null, name: 'NPC', npcState: 2 }); + const city = { + id: 1, + name: '업', + nationId: 1, + level: 8, + region: 1, + population: 150_000, + populationMax: 620_500, + agriculture: 1_000, + agricultureMax: 12_500, + commerce: 1_000, + commerceMax: 11_300, + security: 1_000, + securityMax: 10_000, + trust: 80, + trade: 100, + defence: 5_000, + defenceMax: 11_700, + wall: 5_000, + wallMax: 12_200, + }; + const db = { + general: { + findFirst: vi.fn(async () => actor), + findMany: vi.fn(async ({ where }: { where: Record }) => { + if ('cityId' in where) return [actor, npc]; + if ('officerLevel' in where) return []; + if ('nationId' in where) return [actor, npc]; + return []; + }), + }, + nation: { + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#008000', level: 1, meta: {} })), + findMany: vi.fn(async () => [{ id: 1, name: '위', color: '#008000', level: 1, meta: {} }]), + }, + city: { findMany: vi.fn(async () => [city]) }, + worldState: { + findFirst: vi.fn(async () => ({ config: {}, meta: { turntime: '2026-01-01 10:02:00' } })), + }, + generalTurn: { + findMany: vi.fn(async () => [ + { + generalId: 1, + turnIdx: 0, + actionCode: 'che_징병', + arg: { crewType: 1, amount: 300 }, + }, + ]), + }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: authenticated ? token() : null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', + }; + return { caller: appRouter.createCaller(context), db }; +}; + +describe('world current-city command projection', () => { + it('returns the first five own-user turns as canonical action and args while redacting NPC turns', async () => { + const { caller, db } = fixture(); + + const result = await caller.world.getCurrentCity(); + + expect(result.generals.find((entry) => entry.id === 1)?.turns).toEqual([ + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + ]); + expect(result.generals.find((entry) => entry.id === 2)?.turns).toEqual([]); + expect(db.generalTurn.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { generalId: { in: [1] }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, + }) + ); + }); + + it('keeps authentication and input validation in front of the city read model', async () => { + await expect(fixture(false).caller.world.getCurrentCity()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(fixture().caller.world.getCurrentCity({ cityId: 0 })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); +}); diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 560e2b67..bcd7b16b 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -431,13 +431,20 @@ test('nation and general directories preserve the fixed legacy Chromium geometry } }); -test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => { - await install(page); +test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => { + const requestedOperations: string[] = []; + await install(page, 'general', [], requestedOperations); await page.goto('general-list'); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await page.selectOption('#viewType', '8'); await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); + await page.getByRole('button', { name: '정렬하기' }).click(); await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1); + await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81'); await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6'); @@ -489,10 +496,10 @@ test('directory sort controls stay legible in dark mode and sortable headers app expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px'); await page.mouse.up(); - await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); + await page.getByRole('button', { name: /^삭턴 내림차순/u }).click(); await expect(select).toHaveValue('8'); - await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); - await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴'); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 500, height: 844 }); @@ -501,6 +508,82 @@ test('directory sort controls stay legible in dark mode and sortable headers app await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true }); }); +test('name and mixed-direction stable sorting reuse one response until explicit refresh', async ({ page }) => { + const requestedOperations: string[] = []; + await install(page, 'general', [], requestedOperations); + await page.goto('general-list'); + + const rows = page.locator('tbody tr[data-general-id]'); + await page.getByRole('button', { name: /^이름 내림차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); + await page.getByRole('button', { name: /^이름 오름차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '20'); + await page.getByRole('button', { name: /^이름 정렬 해제/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); + + await page.getByRole('button', { name: /^통솔 내림차순/u }).click(); + await page.getByRole('button', { name: /^무력 내림차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '20'); + await expect( + page.getByRole('columnheader').filter({ hasText: '통솔' }).locator('.legacy-sort-indicator') + ).toHaveText('▼2'); + await page.getByRole('button', { name: /^무력 오름차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1); + + await page.getByRole('button', { name: '갱 신' }).click(); + await expect + .poll(() => requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory').length) + .toBe(2); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); +}); + +test('trait and injury explanations appear on pointer hover and keyboard focus', async ({ page }, testInfo) => { + await install(page); + await page.goto('general-list'); + + const personality = page.locator('[data-directory-tooltip="personality-10"]'); + await personality.hover(); + await expect(personality.getByRole('tooltip')).toContainText('성격 · 대담'); + await expect(personality.getByRole('tooltip')).toContainText('대담한 성격'); + + const domestic = page.locator('[data-directory-tooltip="special-domestic-10"]'); + await domestic.hover(); + await expect(domestic.getByRole('tooltip')).toContainText('내정 특기 · 상재'); + await expect(domestic.getByRole('tooltip')).toContainText('상업 특기'); + + const war = page.locator('[data-directory-tooltip="special-war-10"]'); + await war.focus(); + await expect(war.getByRole('tooltip')).toContainText('전투 특기 · 귀모'); + await expect(war.getByRole('tooltip')).toContainText('전투 특기'); + + const injury = page.locator('[data-directory-tooltip="injury-leadership-10"]'); + await injury.hover(); + await expect(injury.getByRole('tooltip')).toContainText('부상 10%'); + await expect(injury.getByRole('tooltip')).toContainText('원래 통솔 90 → 적용 81'); + const tooltipGeometry = await injury.getByRole('tooltip').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: window.innerWidth - rect.right, + display: style.display, + background: style.backgroundColor, + color: style.color, + fontSize: style.fontSize, + }; + }); + expect(tooltipGeometry.left).toBeGreaterThanOrEqual(8); + expect(tooltipGeometry.right).toBeGreaterThanOrEqual(8); + expect(tooltipGeometry).toMatchObject({ + display: 'block', + background: 'rgb(16, 16, 16)', + color: 'rgb(245, 245, 245)', + fontSize: '12.5px', + }); + await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true }); +}); + test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => { await install(page); await page.goto('npc-list'); @@ -544,7 +627,8 @@ test('nation directory reuses only the public general-directory row on hover and await expect(preview.locator('[data-general-card-id]')).toHaveCount(1); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담'); - await expect(preview.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); + await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); + await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); await expect(preview).not.toContainText('user-'); await expect(preview).not.toContainText('secret'); @@ -687,8 +771,27 @@ test('nation and general directories rearrange for mobile and keep the tapped pr await expect(page.locator('.general-table')).toBeHidden(); await expect(page.locator('.general-card-list')).toBeVisible(); await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2); - await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); - await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6'); + await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); + await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); + await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText( + '81' + ); + await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6'); + const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]'); + await mobileInjury.focus(); + await expect(mobileInjury.getByRole('tooltip')).toBeVisible(); + const mobileTooltipGeometry = await mobileInjury.getByRole('tooltip').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: window.innerWidth - rect.right, + width: rect.width, + innerWidth: window.innerWidth, + }; + }); + expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8); + expect(mobileTooltipGeometry.right).toBeGreaterThanOrEqual(8); + expect(mobileTooltipGeometry.width).toBeLessThanOrEqual(mobileTooltipGeometry.innerWidth - 16); const generalMetrics = await page.locator('.directory-page').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -760,11 +863,12 @@ test('a reused image element falls back for each newly broken account icon', asy await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); }); -test('a failed resort retains the selected value and existing rows', async ({ page }) => { +test('a failed explicit refresh retains the local sort selection and existing rows', async ({ page }) => { await install(page, 'error-after-load'); await page.goto('general-list'); await page.selectOption('#viewType', '8'); await page.getByRole('button', { name: '정렬하기' }).click(); + await page.getByRole('button', { name: '갱 신' }).click(); await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); await expect(page.locator('#viewType')).toHaveValue('8'); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 9ef6d6c5..2270d1ce 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -210,7 +210,44 @@ const install = async ( } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMap') return response(mapFixture); - if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.getCommandTable') + return response({ + general: [ + { + category: '군사', + values: [ + { + key: 'che_징병', + name: '징병', + reqArg: true, + status: 'needsInput', + possible: true, + inputFields: [], + }, + { + key: 'che_화계', + name: '화계', + reqArg: true, + status: 'needsInput', + possible: true, + inputFields: [], + }, + ], + }, + ], + nation: [], + inputOptions: { + cities: [{ value: 1, label: '업 (아국)' }], + nations: [], + generals: [], + crewTypes: [{ value: 1, label: '보병' }], + armTypes: [], + nationTypes: [], + colors: [], + items: {}, + recruitment: null, + }, + }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: [], revision: 0 }); } @@ -373,7 +410,12 @@ const install = async ( crew: 500, train: 90, atmos: 90, - turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'], + turns: denseCurrentCity + ? [ + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + { action: 'che_화계', args: { destCityId: 1 } }, + ] + : [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }], }, ...(denseCurrentCity ? Array.from({ length: 12 }, (_, index) => ({ @@ -475,6 +517,27 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'global-info'); + const desktopOptionsTrigger = page.getByRole('button', { name: '지도 옵션' }); + await expect(desktopOptionsTrigger).toBeVisible(); + await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false'); + await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(52, 92, 133)'); + await desktopOptionsTrigger.hover(); + await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(40, 73, 105)'); + await expect(page.getByRole('button', { name: '도시명 표기 끄기' })).not.toBeVisible(); + await desktopOptionsTrigger.click(); + const cityNameToggle = page.getByRole('button', { name: '도시명 표기 끄기' }); + await expect(cityNameToggle).toBeVisible(); + await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('.map-options-menu .map-toggle')).toHaveCount(1); + await page.keyboard.press('Tab'); + await desktopOptionsTrigger.focus(); + await expect(desktopOptionsTrigger).toHaveCSS('outline-style', 'solid'); + await page.screenshot({ path: testInfo.outputPath('desktop-map-options-open.png'), fullPage: true }); + await cityNameToggle.click(); + await expect(page.locator('.map-area .city-name')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false'); + const desktopCity = page.locator('.city-base').first(); await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); await desktopCity.hover(); @@ -508,20 +571,48 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or await install(mobilePage); await go(mobilePage, 'global-info'); + const mobileOptionsTrigger = mobilePage.getByRole('button', { name: '지도 옵션' }); + await expect(mobileOptionsTrigger).toBeVisible(); + await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false'); + await mobileOptionsTrigger.tap(); + await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'true'); + await expect(mobilePage.locator('.map-options-menu .map-toggle')).toHaveCount(2); + const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }); await expect(twoTapButton).toBeVisible(); await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false'); - const controlGeometry = await twoTapButton.evaluate((element) => { - const rect = element.getBoundingClientRect(); + const controlGeometry = await mobilePage.locator('.map-controls').evaluate((element) => { + const triggerRect = element.querySelector('.map-options-trigger')!.getBoundingClientRect(); + const menuRect = element.querySelector('.map-options-menu')!.getBoundingClientRect(); + const optionRects = Array.from(element.querySelectorAll('.map-options-menu .map-toggle')).map((option) => { + const rect = option.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + }); const mapRect = element.closest('.map-area')?.getBoundingClientRect(); - const style = getComputedStyle(element); + const optionStyle = getComputedStyle(element.querySelector('.map-toggle')!); return { - right: rect.right, - bottom: rect.bottom, - mapRight: mapRect?.right, - mapBottom: mapRect?.bottom, - fontSize: style.fontSize, - lineHeight: style.lineHeight, + trigger: { + right: triggerRect.right, + bottom: triggerRect.bottom, + top: triggerRect.top, + }, + menu: { + left: menuRect.left, + right: menuRect.right, + top: menuRect.top, + bottom: menuRect.bottom, + }, + optionRects, + map: mapRect + ? { + left: mapRect.left, + right: mapRect.right, + top: mapRect.top, + bottom: mapRect.bottom, + } + : null, + fontSize: optionStyle.fontSize, + lineHeight: optionStyle.lineHeight, documentWidth: document.documentElement.scrollWidth, viewportWidth: document.documentElement.clientWidth, overflowing: Array.from(document.querySelectorAll('body *')) @@ -543,8 +634,20 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or overflowing: [], }); expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1); - expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1); - expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1); + expect(controlGeometry.map).not.toBeNull(); + expect(controlGeometry.map!.right - controlGeometry.trigger.right).toBeCloseTo(4, 1); + expect(controlGeometry.map!.bottom - controlGeometry.trigger.bottom).toBeCloseTo(4, 1); + expect(controlGeometry.menu.left).toBeGreaterThanOrEqual(controlGeometry.map!.left); + expect(controlGeometry.menu.right).toBeLessThanOrEqual(controlGeometry.map!.right); + expect(controlGeometry.menu.top).toBeGreaterThanOrEqual(controlGeometry.map!.top); + expect(controlGeometry.menu.bottom).toBeLessThan(controlGeometry.trigger.top); + expect(controlGeometry.optionRects).toHaveLength(2); + expect(controlGeometry.optionRects[0]!.bottom).toBeLessThanOrEqual(controlGeometry.optionRects[1]!.top); + expect(controlGeometry.optionRects[1]!.bottom).toBeLessThanOrEqual(controlGeometry.menu.bottom); + await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-options-open.png'), fullPage: true }); + + await mobilePage.locator('.map-area').tap({ position: { x: 10, y: 10 } }); + await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false'); const mobileCities = mobilePage.locator('.city-base'); await mobileCities.nth(0).tap(); @@ -560,17 +663,19 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u); await go(mobilePage, 'global-info'); + await mobilePage.getByRole('button', { name: '지도 옵션' }).tap(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click(); const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }); await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes'); await mobilePage.reload(); - await expect(singleTapButton).toBeVisible(); + await expect(singleTapButton).not.toBeVisible(); await mobilePage.locator('.city-base').nth(2).tap(); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u); await go(mobilePage, 'global-info'); + await mobilePage.getByRole('button', { name: '지도 옵션' }).tap(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click(); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); await mobilePage.locator('.city-base').first().tap(); @@ -1019,8 +1124,10 @@ test('current-city wraps dense general names and only shrinks reserved turns', a const rows = page.locator('.generals tbody tr'); const reservedTurns = rows.nth(0).locator('.turns'); const npcTurns = rows.nth(1).locator('.turns'); - await expect(reservedTurns).toContainText('1 : 징병'); - await expect(reservedTurns).toContainText('2 : 훈련'); + await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병'); + await expect(reservedTurns).toContainText('2 : 【업】에 화계실행'); + await expect(reservedTurns.locator('.turn-line').nth(0)).toHaveAttribute('title', '【보병】 300명 징병'); + await expect(reservedTurns.locator('.turn-line').nth(1)).toHaveAttribute('title', '【업】에 화계실행'); await expect(reservedTurns).toHaveClass(/turns--reserved/); await expect(npcTurns).toHaveText('NPC 장수'); await expect(npcTurns).not.toHaveClass(/turns--reserved/); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2f5f31ea..948fc5c3 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -2186,7 +2186,43 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false)); await expect(selectedMenu).not.toHaveAttribute('open', ''); - await page.locator('[data-main-target="commands"] .select-command').click(); + const selectCommand = page.locator('[data-main-target="commands"] .select-command'); + await expect(selectCommand).toHaveClass(/legacy-button--info/u); + await page.mouse.move(1, 1); + const measureSelectCommand = () => + selectCommand.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + bottom: rect.bottom, + height: rect.height, + marginTop: style.marginTop, + borderBottomWidth: style.borderBottomWidth, + borderRadius: style.borderRadius, + backgroundColor: style.backgroundColor, + }; + }); + const selectDefault = await measureSelectCommand(); + expect(selectDefault).toMatchObject({ + height: 34, + marginTop: '0px', + borderBottomWidth: '4px', + borderRadius: '5.25px', + backgroundColor: 'rgb(52, 152, 219)', + }); + await selectCommand.hover(); + const selectHover = await measureSelectCommand(); + expect(selectHover).toMatchObject({ height: 33, marginTop: '1px', borderBottomWidth: '3px' }); + expect(selectHover.bottom).toBeCloseTo(selectDefault.bottom, 2); + const selectBox = await selectCommand.boundingBox(); + if (!selectBox) throw new Error('select command control is not measurable'); + await page.mouse.move(selectBox.x + selectBox.width / 2, selectBox.y + selectBox.height / 2); + await page.mouse.down(); + const selectActive = await measureSelectCommand(); + expect(selectActive).toMatchObject({ height: 32, marginTop: '2px', borderBottomWidth: '2px' }); + expect(selectActive.bottom).toBeCloseTo(selectDefault.bottom, 2); + await page.mouse.up(); const picker = page.getByTestId('command-picker'); await expect(picker).toBeVisible(); // The trigger can end up directly above a newly opened category button. diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 906c40d2..47f4426c 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -205,6 +205,86 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns', await expect(page.locator('#nation-general-list')).toContainText('?'); }); +test('nation generals top controls share fixed Lumen state geometry on desktop and mobile', async ({ + page, +}, testInfo) => { + await install(page); + const evidence: Record = {}; + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await page.goto('nation/generals'); + await expect(page.locator('#nation-general-list')).toBeVisible(); + const controls = [ + page.getByRole('button', { name: '돌아가기' }), + page.getByRole('button', { name: '갱신' }), + page.getByRole('button', { name: '보기 모드⌄' }), + page.getByRole('button', { name: '열 선택⌄' }), + ]; + const viewportEvidence: Record = {}; + + for (const control of controls) { + const label = (await control.textContent())?.trim() ?? 'unknown'; + await expect(control).toHaveClass(/legacy-button--fixed-height/u); + const measure = () => + control.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + bottom: rect.bottom, + height: rect.height, + marginTop: style.marginTop, + borderBottomWidth: style.borderBottomWidth, + borderRadius: style.borderRadius, + backgroundColor: style.backgroundColor, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + }; + }); + await page.mouse.move(viewport.width - 1, viewport.height - 1); + const base = await measure(); + expect(base).toMatchObject({ + height: 32, + marginTop: '0px', + borderBottomWidth: '4px', + borderRadius: '5.25px', + fontSize: '14px', + }); + expect(base.fontFamily).toContain('Pretendard'); + + await control.hover(); + const hover = await measure(); + expect(hover).toMatchObject({ height: 31, marginTop: '1px', borderBottomWidth: '3px' }); + expect(hover.bottom).toBeCloseTo(base.bottom, 2); + + const box = await control.boundingBox(); + if (!box) throw new Error(`${label} control is not measurable`); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + const active = await measure(); + expect(active).toMatchObject({ height: 30, marginTop: '2px', borderBottomWidth: '2px' }); + expect(active.bottom).toBeCloseTo(base.bottom, 2); + await page.mouse.move(viewport.width - 1, viewport.height - 1); + await page.mouse.up(); + viewportEvidence[label] = { default: base, hover, active }; + } + evidence[`${viewport.width}x${viewport.height}`] = viewportEvidence; + await page.screenshot({ + path: testInfo.outputPath(`nation-general-buttons-${viewport.width}.png`), + fullPage: true, + }); + } + + await testInfo.attach('nation-general-button-geometry', { + body: JSON.stringify(evidence, null, 2), + contentType: 'application/json', + }); +}); + test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); @@ -405,17 +485,20 @@ test('secret office renders five Ref-style command briefs and the forbidden erro '5 : 휴식', ]); await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여'); - const geometry = await page.locator('#secret-general-list .turns').first().evaluate((element) => { - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - width: rect.width, - height: rect.height, - fontSize: style.fontSize, - textAlign: style.textAlign, - horizontalOverflow: element.scrollWidth - element.clientWidth, - }; - }); + const geometry = await page + .locator('#secret-general-list .turns') + .first() + .evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + fontSize: style.fontSize, + textAlign: style.textAlign, + horizontalOverflow: element.scrollWidth - element.clientWidth, + }; + }); expect(geometry.width).toBeGreaterThanOrEqual(190); expect(geometry.width).toBeLessThanOrEqual(230); expect(geometry.height).toBeGreaterThanOrEqual(60); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 1b539f13..9fa44672 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -395,7 +395,10 @@ test('join refresh shows the assigned preliminary group immediately with accessi await refresh.focus(); await expect(refresh).toBeFocused(); await refresh.hover(); - await expect(refresh).toHaveCSS('filter', 'brightness(1.25)'); + await expect(refresh).toHaveCSS('filter', 'none'); + await expect(refresh).toHaveCSS('height', '43px'); + await expect(refresh).toHaveCSS('margin-top', '1px'); + await expect(refresh).toHaveCSS('border-bottom-width', '3px'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); @@ -602,7 +605,11 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n await expect(dialog.getByText('예상 환수금 280')).toBeVisible(); await dialog.getByLabel('베팅 금액').selectOption('50'); await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible(); - await persistScreenshot(page, 'tournament-betting-dialog-mobile', testInfo.outputPath('betting-dialog-mobile.webp')); + await persistScreenshot( + page, + 'tournament-betting-dialog-mobile', + testInfo.outputPath('betting-dialog-mobile.webp') + ); await dialog.getByRole('button', { name: '베팅 등록' }).click(); await expect(dialog).not.toBeVisible(); await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.'); diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css index b7111403..2b2fcba0 100644 --- a/app/game-frontend/src/assets/styles/legacy-controls.css +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -144,7 +144,7 @@ /* * Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised * edge and pressed movement. Semantic modifiers below only select face, edge, - * and text colors; width and fixed-height compensation stay with the owner. + * and text colors; width and the optional fixed-height value stay with the owner. * Existing semantic modifiers also opt in for backward compatibility. */ .legacy-button:is( @@ -173,6 +173,15 @@ vertical-align: middle; } +/* + * Top bars and other fixed rows keep their owner-provided height while using + * the same Lumen edge movement. Shrinking the box with the edge keeps its + * bottom coordinate fixed instead of moving the whole control down. + */ +.legacy-button.legacy-button--fixed-height { + height: var(--legacy-button-height); +} + .legacy-button.legacy-button--secondary { --legacy-button-bg: var(--sammo-button-secondary-bg); --legacy-button-border: var(--sammo-button-secondary-border); @@ -213,6 +222,10 @@ background: var(--legacy-button-bg); } +.legacy-button.legacy-button--fixed-height:not(:disabled, [aria-disabled='true']):is(:hover, [aria-expanded='true']) { + height: calc(var(--legacy-button-height) - 1px); +} + .legacy-button:is( .legacy-button--lumen, .legacy-button--primary, @@ -244,6 +257,10 @@ box-shadow: none; } +.legacy-button.legacy-button--fixed-height:not(:disabled, [aria-disabled='true']):active { + height: calc(var(--legacy-button-height) - 2px); +} + .legacy-button:is( .legacy-button--lumen, .legacy-button--primary, diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 0fc29698..b7ff6956 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -588,7 +588,13 @@ const clickOutsideMenu = (event: Event) => { - +
@@ -806,8 +812,7 @@ const clickOutsideMenu = (event: Event) => { } .control-pad > button, .clock, -.legacy-menu > summary, -.select-command { +.legacy-menu > summary { box-sizing: border-box; min-height: 34px; border: 0; @@ -822,6 +827,12 @@ const clickOutsideMenu = (event: Event) => { cursor: pointer; list-style: none; } +.select-command { + --legacy-button-height: 34px; + display: grid; + place-items: center; + padding: 4px; +} .clock { background: #345c85; font-variant-numeric: tabular-nums; @@ -1024,9 +1035,6 @@ const clickOutsideMenu = (event: Event) => { grid-template-columns: 5fr 7fr; order: 1; } -.advanced-actions > * { - border-radius: 0 !important; -} .bottom-actions { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/app/game-frontend/src/components/directory/DirectoryTooltip.vue b/app/game-frontend/src/components/directory/DirectoryTooltip.vue new file mode 100644 index 00000000..9106f8e3 --- /dev/null +++ b/app/game-frontend/src/components/directory/DirectoryTooltip.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue b/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue new file mode 100644 index 00000000..a753c651 --- /dev/null +++ b/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue index 141d09cb..0d219719 100644 --- a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue +++ b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue @@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera import { formatOfficerLevelText } from '../../utils/nationFormat'; import { getNpcColor } from '../../utils/npcColor'; import type { GeneralDirectoryGeneral } from '../../types/directory'; +import type { GeneralDirectorySortCriterion, GeneralDirectorySortKey } from '../../utils/generalDirectorySort'; +import DirectoryTooltip from './DirectoryTooltip.vue'; +import GeneralDirectoryStat from './GeneralDirectoryStat.vue'; type SortDirection = 'ascending' | 'descending'; type Header = { label: string; - sort?: number; - direction?: SortDirection; - title?: string; + sort?: GeneralDirectorySortKey; }; const props = withDefaults( @@ -17,12 +18,12 @@ const props = withDefaults( generals: GeneralDirectoryGeneral[]; loading?: boolean; layout?: 'responsive' | 'card'; - activeSort?: number; + sortCriteria?: readonly GeneralDirectorySortCriterion[]; }>(), { loading: false, layout: 'responsive', - activeSort: undefined, + sortCriteria: () => [], } ); @@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>(); const headers: ReadonlyArray
= [ { label: '얼 굴' }, - { label: '이 름' }, - { label: '연령', sort: 14, direction: 'descending' }, - { label: '성격', sort: 11, direction: 'descending' }, + { label: '이 름', sort: 0 }, + { label: '연령', sort: 14 }, + { label: '성격', sort: 11 }, { label: '특기' }, - { label: '레 벨', sort: 10, direction: 'descending' }, - { label: '국 가', sort: 1, direction: 'ascending' }, - { label: '명 성', sort: 5, direction: 'descending' }, - { label: '계 급', sort: 6, direction: 'descending' }, - { label: '관 직', sort: 7, direction: 'descending' }, - { label: '통솔', sort: 2, direction: 'descending' }, - { label: '무력', sort: 3, direction: 'descending' }, - { label: '지력', sort: 4, direction: 'descending' }, - { label: '삭턴', sort: 8, direction: 'ascending' }, - { label: '벌점', sort: 9, direction: 'descending' }, + { label: '레 벨', sort: 10 }, + { label: '국 가', sort: 1 }, + { label: '명 성', sort: 5 }, + { label: '계 급', sort: 6 }, + { label: '관 직', sort: 7 }, + { label: '통솔', sort: 2 }, + { label: '무력', sort: 3 }, + { label: '지력', sort: 4 }, + { label: '삭턴', sort: 8 }, + { label: '벌점', sort: 9 }, ]; +const criterionIndex = (header: Header): number => + header.sort === undefined ? -1 : props.sortCriteria.findIndex(({ key }) => key === header.sort); +const criterionFor = (header: Header): GeneralDirectorySortCriterion | undefined => { + const index = criterionIndex(header); + return index < 0 ? undefined : props.sortCriteria[index]; +}; const ariaSort = (header: Header): SortDirection | undefined => - header.sort === props.activeSort ? header.direction : undefined; - -const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); + criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined; +const sortIndicator = (header: Header): string => { + const index = criterionIndex(header); + if (index < 0) return '↕'; + const arrow = criterionFor(header)?.direction === 'ascending' ? '▲' : '▼'; + return props.sortCriteria.length > 1 ? `${arrow}${index + 1}` : arrow; +}; +const nextSortAction = (header: Header): string => { + const direction = criterionFor(header)?.direction; + if (!direction) return '내림차순'; + return direction === 'descending' ? '오름차순' : '정렬 해제'; +}; +const sortHelp = (header: Header): string => + `${header.label.replaceAll(' ', '')} ${nextSortAction(header)}. 같은 값은 이전 정렬 순서를 유지합니다.`;