diff --git a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts new file mode 100644 index 00000000..ed3dfc5f --- /dev/null +++ b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts @@ -0,0 +1,420 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +type Role = 'head' | 'member'; +type AppointmentInput = { destGeneralId: number; destCityId: number; officerLevel: number }; +type FixtureState = { + role: Role; + appointed: boolean; + secretForbidden?: boolean; + appointmentInputs: AppointmentInput[]; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({ + error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } }, +}); +const operations = (route: Route): string[] => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const requestInput = (route: Route, index: number): Record => { + const body: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const record = body && typeof body === 'object' ? (body as Record) : {}; + const raw = record[String(index)] ?? record; + const payload = raw && typeof raw === 'object' ? (raw as Record) : {}; + const input = payload.input && typeof payload.input === 'object' ? (payload.input as Record) : {}; + const json = payload.json ?? input.json ?? payload; + return json && typeof json === 'object' ? (json as Record) : {}; +}; + +const cities = [ + { + id: 1, + name: '허창', + level: 7, + region: 2, + population: 99_000, + populationMax: 100_000, + agriculture: 9_500, + agricultureMax: 10_000, + commerce: 8_000, + commerceMax: 10_000, + security: 8_000, + securityMax: 10_000, + trust: 80, + trade: 100, + defence: 4_500, + defenceMax: 5_000, + wall: 4_500, + wallMax: 5_000, + supplyState: 1, + frontState: 0, + incomes: { gold: 1000, rice: 900, wall: 800 }, + }, + { + id: 2, + name: '낙양', + level: 6, + region: 2, + population: 60_000, + populationMax: 100_000, + agriculture: 5_000, + agricultureMax: 10_000, + commerce: 5_000, + commerceMax: 10_000, + security: 5_000, + securityMax: 10_000, + trust: 70, + trade: 90, + defence: 2_500, + defenceMax: 5_000, + wall: 2_500, + wallMax: 5_000, + supplyState: 1, + frontState: 0, + incomes: { gold: 800, rice: 700, wall: 600 }, + }, +] as const; + +const overviewFixture = (state: FixtureState) => ({ + me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 }, + nation: { + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + rate: 20, + }, + chiefStatMin: 65, + cities: cities.map((city) => ({ + ...city, + officers: { + 4: state.appointed + ? { id: 21, name: '장료', npcState: 0, officerLevel: 4, cityId: 1, cityName: '허창' } + : null, + 3: null, + 2: null, + }, + })), + generals: [ + { + id: 1, + name: '조조', + npcState: 0, + officerLevel: 12, + cityId: 1, + officerCity: 0, + stats: { leadership: 90, strength: 80, intelligence: 90 }, + }, + { + id: 20, + name: '순욱', + npcState: 0, + officerLevel: 5, + cityId: 1, + officerCity: 0, + stats: { leadership: 75, strength: 70, intelligence: 90 }, + }, + { + id: 21, + name: '장료', + npcState: 0, + officerLevel: state.appointed ? 4 : 1, + cityId: 1, + officerCity: state.appointed ? 1 : 0, + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }, + { + id: 22, + name: '조홍', + npcState: 2, + officerLevel: 1, + cityId: 2, + officerCity: 0, + stats: { leadership: 60, strength: 65, intelligence: 40 }, + }, + ], +}); + +const secretGeneral = (id: number, name: string, cityId: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + injury: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId, + cityName: cityId === 1 ? '허창' : '낙양', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crewTypeName: '보병', + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: '2026-01-01T01:02:00.000Z', + reservedCommands: ['농지 개간', '훈련'], + ...overrides, +}); + +const secretFixture = () => ({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 20, permission: 1 }, + summary: { + gold: 4000, + rice: 8000, + crew: 1200, + generalCount: 4, + averageGold: 1000, + averageRice: 2000, + readiness: { + 90: { crew: 1200, generals: 4 }, + 80: { crew: 1200, generals: 4 }, + 60: { crew: 1200, generals: 4 }, + }, + }, + generals: [ + secretGeneral(1, '조조', 1, { leadershipBonus: 6 }), + secretGeneral(20, '순욱', 1, { leadershipBonus: 3 }), + secretGeneral(21, '장료', 1, { + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }), + secretGeneral(22, '조홍', 2, { npcState: 2, reservedCommands: [] }), + ], +}); + +const personnelGeneral = (id: number, name: string, officerLevel: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + officerLevel, + cityId: 1, + cityName: '허창', + troopId: 0, + troopName: null, + picture: null, + imageServer: 0, + officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0, + officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1000, + rice: 1000, + crew: 100, + personality: null, + specialDomestic: null, + specialWar: null, + belong: 10, + permission: 'normal', + ...overrides, +}); + +const personnelFixture = (state: FixtureState) => { + const allGenerals = [ + personnelGeneral(1, '조조', 12), + personnelGeneral(20, '순욱', 5, { stats: { leadership: 75, strength: 70, intelligence: 90 } }), + personnelGeneral(21, '장료', state.appointed ? 4 : 1, { + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }), + personnelGeneral(22, '조홍', 1, { + npcState: 2, + cityId: 2, + cityName: '낙양', + stats: { leadership: 60, strength: 65, intelligence: 40 }, + }), + ]; + const canManage = state.role === 'head'; + return { + me: { + id: canManage ? 20 : 21, + officerLevel: canManage ? 5 : 1, + canManage, + canChangePermissions: false, + canKick: canManage, + }, + nation: { + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + chiefSet: 0, + }, + chiefStatMin: 65, + generals: canManage ? allGenerals : [], + chiefAssignments: { 12: allGenerals[0], 5: allGenerals[1] }, + cityAssignments: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + region: city.region, + officerSet: city.id === 1 && state.appointed ? 1 << 4 : 0, + officers: { + 4: city.id === 1 && state.appointed ? allGenerals[2] : null, + 3: null, + 2: null, + }, + })), + awards: { tigers: [], eagles: [] }, + permissionCandidates: { ambassadors: [], auditors: [] }, + }; +}; + +const install = async (page: Page, state: FixtureState): Promise => { + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_city_office'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route(gameTrpcRoute, async (route) => { + const result = operations(route).map((operation, index) => { + 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.getSecretGeneralList') { + return state.secretForbidden + ? errorResponse( + operation, + '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + 'FORBIDDEN' + ) + : response(secretFixture()); + } + if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state)); + if (operation === 'nation.appoint') { + const input = requestInput(route, index); + state.appointmentInputs.push({ + destGeneralId: Number(input.destGeneralId), + destCityId: Number(input.destCityId), + officerLevel: Number(input.officerLevel), + }); + state.appointed = true; + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(result) }); + }); +}; + +test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명을 반영한다', async ({ page }, testInfo) => { + const state: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/cities'); + + await expect(page.locator('.nation-cities-page')).toBeVisible(); + await expect(page.locator('.city-user-table')).toHaveCount(0); + await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + + await page.getByRole('button', { name: '암행부 연동' }).click(); + await expect(page.locator('.city-user-table')).toHaveCount(2); + await expect(page.locator('.city[data-city-id="1"] .city-user-table tr[data-general-id="21"]')).toContainText( + '장료' + ); + await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="22"]')).toContainText( + '조홍' + ); + await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="21"]')).toHaveCount(0); + await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .command-attention')).toHaveText( + '농지 개간' + ); + + const integratedBox = await page.locator('.city[data-city-id="1"] .city-user-table').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { width: rect.width, borderCollapse: style.borderCollapse, fontSize: style.fontSize }; + }); + expect(integratedBox).toEqual({ width: 941, borderCollapse: 'collapse', fontSize: '14px' }); + + await page.getByRole('button', { name: '인사부 연동' }).click(); + const ordinaryRow = page.locator('.city[data-city-id="1"] tr[data-general-id="21"]'); + await expect(ordinaryRow.locator('.appointment-button')).toHaveCount(3); + await expect(ordinaryRow.locator('.mode-4')).toBeEnabled(); + await expect(ordinaryRow.locator('.mode-3')).toBeDisabled(); + await expect(ordinaryRow.locator('.mode-2')).toBeEnabled(); + await expect(page.locator('tr[data-general-id="1"] .appointment-button')).toHaveCount(0); + + const disabledStyle = await ordinaryRow.locator('.mode-3').evaluate((button) => { + const style = getComputedStyle(button); + return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor }; + }); + expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' }); + const appointButton = page.getByRole('button', { name: '장료을(를) 허창 태수로 임명' }); + await appointButton.hover(); + expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await appointButton.focus(); + await expect(appointButton).toBeFocused(); + + await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true }); + await appointButton.click(); + await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]); + await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료'); + await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u); + await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled(); + + await page.setViewportSize({ width: 500, height: 900 }); + expect(await page.locator('.nation-cities-page').evaluate((element) => element.getBoundingClientRect().width)).toBe( + 1000 + ); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-mobile.png'), fullPage: true }); +}); + +test('수뇌 대상은 재확인하고 일반 장수에게는 임명 버튼을 열지 않는다', async ({ page }) => { + const headState: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + await install(page, headState); + await page.goto('nation/cities'); + await page.getByRole('button', { name: '암행부 연동' }).click(); + await page.getByRole('button', { name: '인사부 연동' }).click(); + + const chiefButton = page.getByRole('button', { name: '순욱을(를) 허창 태수로 임명' }); + expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수뇌입니다. 임명할까요?'); + await dialog.dismiss(); + }); + await chiefButton.click(); + await expect.poll(() => headState.appointmentInputs.length).toBe(0); + + await page.unroute(gameTrpcRoute); + const memberState: FixtureState = { role: 'member', appointed: false, appointmentInputs: [] }; + await install(page, memberState); + await page.reload(); + await page.getByRole('button', { name: '암행부 연동' }).click(); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수뇌가 아닙니다!'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '인사부 연동' }).click(); + await expect(page.locator('.appointment-button')).toHaveCount(0); + expect(memberState.appointmentInputs).toEqual([]); +}); + +test('암행부 권한 거부는 도시 기밀 행과 인사부 연동을 열지 않는다', async ({ page }) => { + const state: FixtureState = { + role: 'member', + appointed: false, + secretForbidden: true, + appointmentInputs: [], + }; + await install(page, state); + await page.goto('nation/cities'); + await page.getByRole('button', { name: '암행부 연동' }).click(); + + await expect(page.locator('.integration-error')).toContainText('권한이 부족합니다.'); + await expect(page.locator('.city-user-table')).toHaveCount(0); + await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + expect(state.appointmentInputs).toEqual([]); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 8fe08af6..734b6cee 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -21,6 +21,7 @@ export default defineConfig({ 'troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', + 'nationCityOfficeIntegration.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts', 'diplomacy.spec.ts', diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index 10669e95..ae7a786b 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -1,16 +1,28 @@