diff --git a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts index 4d03ec05..7eda1585 100644 --- a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts +++ b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts @@ -1,4 +1,7 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; + +import type { trpc } from '../src/utils/trpc.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { expectLumenButtonStates } from './lumenButton.js'; @@ -306,7 +309,11 @@ const personnelFixture = (state: FixtureState) => { }; }; -const install = async (page: Page, state: FixtureState): Promise => { +const install = async ( + page: Page, + state: FixtureState, + overviewCities?: Awaited>['cities'] +): Promise => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_city_office'); localStorage.setItem('sammo-game-profile', profile); @@ -316,7 +323,10 @@ const install = async (page: Page, state: FixtureState): Promise => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 20, name: '순욱' } }); if (operation === 'join.getConfig') return response({}); - if (operation === 'nation.getCityOverview') return response(overviewFixture(state)); + if (operation === 'nation.getCityOverview') { + const overview = overviewFixture(state); + return response({ ...overview, cities: overviewCities ?? overview.cities }); + } if (operation === 'nation.getSecretGeneralList') { return state.secretForbidden ? errorResponse( @@ -491,3 +501,88 @@ test('NPC prefixes stay single in city names and linked secret rows', async ({ p await page.screenshot({ path: testInfo.outputPath(`npc-city-prefix-${width}.png`), fullPage: true }); } }); + +for (const width of [1200, 500]) { + test(`세력도시 기본 정렬은 지역별로 묶고 같은 지역 안에서 규모순으로 표시한다 (${width}px)`, async ({ + page, + }, testInfo) => { + const state: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + const baseCity = overviewFixture(state).cities[0]!; + // ID·시세·규모순이 지역 우선 정렬과 서로 다르게 되는 입력이다. + const overviewCities = [ + { id: 10, name: '허창', region: 2, level: 8, trade: 60 }, + { id: 20, name: '상당', region: 1, level: 5, trade: 130 }, + { id: 30, name: '진류', region: 2, level: 5, trade: 110 }, + { id: 40, name: '업', region: 1, level: 8, trade: 80 }, + { id: 50, name: '북평', region: 1, level: 8, trade: 90 }, + ].map((city) => ({ ...baseCity, ...city })); + await install(page, state, overviewCities); + await page.setViewportSize({ width, height: 900 }); + await page.goto('nation/cities'); + const tables = page.locator('.city'); + await expect(tables).toHaveCount(5); + await page.evaluate(() => document.fonts.ready); + const cityOrder = () => tables.evaluateAll((elements) => elements.map((el) => Number(el.dataset.cityId))); + const regionStarts = () => + page + .locator('.city.region-break') + .evaluateAll((elements) => elements.map((el) => Number(el.dataset.cityId))); + const expectedOrder = [40, 50, 20, 10, 30]; + const select = page.locator('#nation-city-sort'); + const submit = page.getByRole('button', { name: '정렬하기' }); + + await page.screenshot({ path: testInfo.outputPath('default-region-order.png'), fullPage: true }); + await writeFile( + testInfo.outputPath('default-region-order.html'), + await page.locator('main').evaluate((el) => el.outerHTML) + ); + const geometry = await tables.evaluateAll((elements) => + elements.map((el) => { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return { + id: Number(el.dataset.cityId), + title: el.querySelector('.city-title')?.textContent?.trim(), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + marginTop: style.marginTop, + fontSize: style.fontSize, + borderCollapse: style.borderCollapse, + }; + }) + ); + await writeFile( + testInfo.outputPath('default-region-order.json'), + JSON.stringify({ url: page.url(), width, geometry }, null, 2) + ); + await expect(select).toHaveValue('1'); + expect(await cityOrder()).toEqual(expectedOrder); + expect(await regionStarts()).toEqual([40, 10]); + expect(geometry.map((city) => city.marginTop)).toEqual(['18px', '0px', '0px', '18px', '0px']); + await expect(tables.locator('.city-title')).toContainText(['하북', '하북', '하북', '중원', '중원']); + + // 선택만으로는 적용하지 않고 submit 뒤에만 기존 명시적 정렬을 적용한다. + await select.selectOption('12'); + expect(await cityOrder()).toEqual(expectedOrder); + await submit.click(); + expect(await cityOrder()).toEqual([40, 50, 10, 20, 30]); + expect(await regionStarts()).toEqual([]); + await page.getByRole('button', { name: '시세 기준 정렬' }).first().click(); + await expect(select).toHaveValue('10'); + expect(await cityOrder()).toEqual([20, 30, 50, 40, 10]); + expect(await regionStarts()).toEqual([]); + for (const sort of ['11', '1']) { + await select.selectOption(sort); + await submit.click(); + expect(await cityOrder()).toEqual(expectedOrder); + expect(await regionStarts()).toEqual([40, 10]); + } + await page.reload(); + await expect(select).toHaveValue('1'); + await expect(tables).toHaveCount(5); + expect(await cityOrder()).toEqual(expectedOrder); + expect(state.appointmentInputs).toEqual([]); + }); +} diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index c39516d4..2f44f92c 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -34,8 +34,8 @@ const integrationError = ref(''); const secretLoading = ref(false); const personnelLoading = ref(false); const pendingAppointment = ref(''); -const sort = ref(10); -const selectedSort = ref(10); +const sort = ref(1); +const selectedSort = ref(1); const extraSort = ref< | 'name' | 'populationRate' @@ -95,7 +95,7 @@ const cities = computed(() => { } return values.sort((a, b) => { const key = sort.value; - if (key === 1) return a.id - b.id; + if (key === 1 || key === 11) return a.region - b.region || b.level - a.level; if (key === 2) return b.population - a.population; if (key === 3) return b.population / b.populationMax - a.population / a.populationMax; if (key === 4) return b.trust - a.trust; @@ -105,7 +105,6 @@ const cities = computed(() => { if (key === 8) return b.defence - a.defence; if (key === 9) return b.wall - a.wall; if (key === 10) return (b.trade ?? -1) - (a.trade ?? -1); - if (key === 11) return a.region - b.region || b.level - a.level; return b.level - a.level || a.region - b.region; }); }); @@ -148,7 +147,9 @@ const developmentClass = ( return ratio > 0.8 ? 'development-high' : ratio > 0.4 ? 'development-mid' : 'development-low'; }; const isRegionBreak = (city: City, index: number) => - sort.value === 10 && extraSort.value === null && (index === 0 || cities.value[index - 1]?.region !== city.region); + (sort.value === 1 || sort.value === 11) && + extraSort.value === null && + (index === 0 || cities.value[index - 1]?.region !== city.region); const officer = (city: City, level: 2 | 3 | 4) => city.officers[level]?.name ?? '-'; const officerIsStationed = (city: City, level: OfficerLevel): boolean => secretData.value !== null && city.officers[level]?.cityId === city.id;