diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 3572cc13..993eeb79 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -260,6 +260,116 @@ describe('messages router missing-flow compatibility', () => { expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); }); + it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => { + const ruler = { ...general, officerLevel: 12 } as GeneralRow; + const queryRaw = vi.fn(async () => [{ id: 53 }]); + const changeJournal = new ChangeJournal(); + const { caller } = buildContext( + { + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => ruler), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })), + }, + }, + { changeJournal } + ); + + const result = await caller.messages.send({ + generalId: ruler.id, + mailbox: 9000, + text: '우리 나라로 와주세요', + }); + + expect(result.msgType).toBe('diplomacy'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy'])); + expect(queryRaw).toHaveBeenCalledTimes(2); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'messages.mailbox', entityId: 9000 }, + { domain: 'messages.mailbox', entityId: 9001 }, + ]); + }); + + it('keeps the wanderer mailbox unavailable to a non-diplomat on the server', async () => { + const queryRaw = vi.fn(async () => [{ id: 54 }]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })), + }, + }); + + const result = await caller.messages.send({ + generalId: general.id, + mailbox: 9000, + text: '권한 없는 재야 광고', + }); + + expect(result.msgType).toBe('national'); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); + }); + + it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => { + const wanderer = { ...general, nationId: 0, officerLevel: 0 } as GeneralRow; + const advertisementRow = { + id: 55, + mailbox: 9000, + type: 'diplomacy', + src: 9001, + dest: 9000, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: 1, + generalName: '위왕', + nationId: 1, + nationName: '위', + color: '#112233', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: '재야', + color: '#000000', + icon: '', + }, + text: '우리 나라로 와주세요', + option: {}, + }, + }; + const queryRaw = vi.fn(async (...args: unknown[]) => { + const values = args.slice(1); + return values.includes(9000) && values.includes('diplomacy') ? [advertisementRow] : []; + }); + const { caller } = buildContext({ + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => wanderer), + findMany: vi.fn(async () => []), + }, + }); + + const result = await caller.messages.getRecent({ generalId: wanderer.id }); + + expect(result.permission).toBe(-1); + expect(result.diplomacy).toEqual([ + expect.objectContaining({ + text: '우리 나라로 와주세요', + dest: expect.objectContaining({ nationId: 0, nationName: '재야' }), + option: {}, + }), + ]); + }); + it('blocks private messages between foreign ambassadors', async () => { const ambassador = { ...general, diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 2b313082..37ad2a7f 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -220,7 +220,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { label: '외교메시지', color: '#000000', options: contacts - .filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0) + .filter((nation) => nation.mailbox !== ownMailbox) .map((nation) => ({ label: nation.name, value: nation.mailbox, diff --git a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts index 30ded23f..cb8be207 100644 --- a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts +++ b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts @@ -1,8 +1,11 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js'; const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; +const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string) => ({ error: { @@ -42,7 +45,35 @@ const general = { const generalContext = { general, city: null, - nation: null, + nation: { + id: 1, + name: '테스트국', + color: '#d32f2f', + level: 1, + levelName: '군벌', + gold: 1000, + rice: 1000, + tech: 1000, + typeCode: 'test', + typeName: '테스트', + typePros: '-', + typeCons: '-', + capitalCityId: 1, + capitalCityName: '낙양', + population: { cityCount: 1, current: 10000, max: 20000 }, + crew: { generalCount: 2, current: 1000, max: 16000 }, + power: 100, + bill: 10, + taxRate: 10, + strategicCommandLimit: 0, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: false, + techLevel: 1, + techLimited: false, + topChiefs: { 12: null, 11: null }, + impossibleStrategicCommands: [], + }, settings: {}, penalties: {}, }; @@ -373,14 +404,25 @@ for (const viewport of [ boxShadow: getComputedStyle(element).boxShadow, })) ).toEqual({ outlineWidth: '0px', boxShadow: 'none' }); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.MessagePanel').screenshot({ + path: resolve(artifactRoot, `message-panel-${viewport.width}.png`), + animations: 'disabled', + }); + } }); } -test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => { +test('exposes nation targets including wanderers, reply, read, delete, and successful send interactions', async ({ + page, +}) => { const mutations = await installFixture(page, { permission: 4 }); await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('optgroup[label="외교메시지"] option[value="9000"]')).toHaveText('재야'); await expect(select.locator('option[value="9002"]')).toHaveCount(1); await expect(select.locator('option[value="8"]')).toBeDisabled(); await expect(select.locator('option[value="9"]')).toBeEnabled(); @@ -396,11 +438,21 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter await deleteButton.click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); - await select.selectOption('9999'); - await page.getByLabel('메시지 입력').fill('전송 성공'); + await select.selectOption('9000'); + await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요'); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.MessageInputForm').screenshot({ + path: resolve(artifactRoot, 'wanderer-recruitment-target-500.png'), + animations: 'disabled', + }); + } await page.getByRole('button', { name: '서신전달&갱신' }).click(); await expect(page.getByLabel('메시지 입력')).toHaveValue(''); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); + expect(JSON.stringify(mutations.find((entry) => entry.operation === 'messages.send')?.body)).toContain( + '"mailbox":9000' + ); }); test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => { @@ -442,6 +494,7 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('option[value="9000"]')).toHaveCount(0); await expect(select.locator('option[value="9002"]')).toHaveCount(0); await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); diff --git a/tools/frontend-legacy-parity/reference-ingame-message.mjs b/tools/frontend-legacy-parity/reference-ingame-message.mjs index 45c147eb..3f2e447e 100644 --- a/tools/frontend-legacy-parity/reference-ingame-message.mjs +++ b/tools/frontend-legacy-parity/reference-ingame-message.mjs @@ -65,6 +65,14 @@ const measure = async (browser, name, viewport) => { await ensureGeneral(page); await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 }); await page.locator('.BoardHeader').first().waitFor({ state: 'visible' }); + const mailboxOptions = await page.locator('.MessageInputForm select option').evaluateAll((options) => + options.map((option) => ({ + value: option.value, + label: option.textContent?.trim() ?? '', + group: option.parentElement?.tagName === 'OPTGROUP' ? option.parentElement.label : '', + disabled: option.disabled, + })) + ); const marker = `computed-dom-${name}-${Date.now()}`; await page.locator('.MessageInputForm select').selectOption('9999'); await page.locator('.MessageInputForm input').fill(marker); @@ -160,7 +168,11 @@ const measure = async (browser, name, viewport) => { page.once('dialog', (dialog) => dialog.accept()); await deleteButton.click(); } - return { ...result, interaction: { hover, focus } }; + return { + ...result, + mailboxOptions, + interaction: { hover, focus }, + }; } finally { await context.close(); }