diff --git a/app/game-api/test/nationHtml.test.ts b/app/game-api/test/nationHtml.test.ts index a9e4d690..2dbd1f73 100644 --- a/app/game-api/test/nationHtml.test.ts +++ b/app/game-api/test/nationHtml.test.ts @@ -69,6 +69,25 @@ describe('nation HTML purification', () => { expect(clean).toContain('https://player.vimeo.com/video/1234'); }); + it('preserves the Tiptap font, color, background, alignment, rule, and uploaded image contract', () => { + const source = [ + '
', + '방침', + '
',
+ ].join('');
+
+ const clean = purifyNationHtml(source);
+
+ expect(clean).toContain('style="text-align:center"');
+ expect(clean).toContain(
+ 'style="font-family:Pretendard, sans-serif;font-size:22px;color:#123456;background-color:#fedcba"'
+ );
+ expect(clean).toContain('방침
'); expect(purifyNationHtml(first)).toBe(first); diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 54cae4c1..1aff2cc9 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -13,6 +13,7 @@ type FixtureState = { appointedGeneralId?: number; noticeMutationInput?: string; scoutMutationInput?: string; + uploadDataUrl?: string; }; type TrpcRequestPayload = { @@ -249,6 +250,17 @@ const installFixture = async (page: Page, state: FixtureState) => { state.scoutMutationInput = typeof jsonInput.msg === 'string' ? jsonInput.msg : undefined; return response({ ok: true, msg: purifiedScoutResponse }); } + if (operation === 'board.uploadImage') { + state.uploadDataUrl = typeof jsonInput.dataUrl === 'string' ? jsonInput.dataUrl : undefined; + return response({ + url: 'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp', + width: 1, + height: 1, + format: 'webp', + animated: false, + size: 68, + }); + } if (['nation.setBill', 'nation.setSecretLimit', 'nation.setBlockScout'].includes(operation)) return response({ ok: true }); if (operation === 'nation.setBlockWar') return response({ availableCnt: 4 }); @@ -342,7 +354,8 @@ test('personnel hides every mutation control for an ordinary member and exposes await gotoOffice(page, 'nation/personnel'); await expect(page.getByText('도 시 관 직 임 명')).toHaveCount(0); await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0); - await expect(page.getByText('추 방', { exact: true })).toHaveCount(0); + await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '추방', exact: true })).toHaveCount(0); await expect(page.getByText(/곽가\(10년\).*허창/)).toBeVisible(); const failed = await page.context().newPage(); @@ -456,6 +469,93 @@ test('finance enforces edit permissions and preserves the old value across an AP await expect(readOnly.getByRole('checkbox', { name: '전쟁 금지' })).toBeDisabled(); }); +test('finance editor preserves Ref formatting controls and uploads images through sam-image', async ({ page }) => { + const state: FixtureState = { role: 'head', rate: 20 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoOffice(page, 'nation/finance'); + await page.getByRole('button', { name: '국가방침 수정' }).click(); + + const editor = page.getByRole('textbox', { name: '국가 방침' }); + const editorFrame = page.locator('#notice-form .legacy-html-editor'); + await expect(editor).toBeVisible(); + expect(await editorFrame.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe( + 'rgba(0, 0, 0, 0)' + ); + expect(await editor.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); + + await editor.fill('서식 검증'); + await editor.press('Control+A'); + await page.getByRole('combobox', { name: '글꼴', exact: true }).selectOption('Gungsuh, serif'); + await page.getByRole('combobox', { name: '글꼴 크기' }).selectOption('22px'); + await page.getByLabel('글자색').evaluate((element) => { + const input = element as HTMLInputElement; + input.value = '#123456'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.getByLabel('배경색').evaluate((element) => { + const input = element as HTMLInputElement; + input.value = '#fedcba'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.getByRole('button', { name: '가운데 정렬' }).click(); + await expect(page.getByRole('button', { name: '가운데 정렬' })).toHaveClass(/active/); + + const formattedText = editor.locator('span', { hasText: '서식 검증' }); + await expect(formattedText).toHaveCSS('font-family', /Gungsuh/); + await expect(formattedText).toHaveCSS('font-size', '22px'); + await expect(formattedText).toHaveCSS('color', 'rgb(18, 52, 86)'); + await expect(formattedText).toHaveCSS('background-color', 'rgb(254, 220, 186)'); + await expect(editor.locator('p')).toHaveCSS('text-align', 'center'); + + await editor.press('End'); + await page.getByRole('button', { name: '구분선' }).click(); + await page.getByLabel('업로드할 이미지').setInputFiles({ + name: '방침.png', + mimeType: 'image/png', + buffer: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ), + }); + + await expect.poll(() => state.uploadDataUrl).toMatch(/^data:image\/png;base64,/); + await expect(editor.locator('hr')).toHaveCount(1); + await expect(editor.locator('img')).toHaveAttribute( + 'src', + 'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp' + ); + + const imageButton = page.getByRole('button', { name: '이미지', exact: true }); + await imageButton.hover(); + await expect(imageButton).toHaveCSS('border-color', 'rgb(157, 200, 240)'); + await page.getByRole('combobox', { name: '글꼴', exact: true }).focus(); + await expect(page.getByRole('combobox', { name: '글꼴', exact: true })).toHaveCSS('outline-style', 'solid'); + await screenshot(page, 'core-finance-editor-desktop.png'); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileGeometry = await editorFrame.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + scrollWidth: element.scrollWidth, + toolbarHeight: element.querySelector('[role="toolbar"]')?.getBoundingClientRect().height ?? 0, + })); + expect(mobileGeometry.width).toBe(500); + expect(mobileGeometry.scrollWidth).toBeLessThanOrEqual(500); + expect(mobileGeometry.toolbarHeight).toBeGreaterThan(24); + await screenshot(page, 'core-finance-editor-mobile.png'); + + await page.locator('#notice-form').getByRole('button', { name: '저장' }).click(); + await expect.poll(() => state.noticeMutationInput).toContain('font-family: Gungsuh, serif'); + expect(state.noticeMutationInput).toContain('font-size: 22px'); + expect(state.noticeMutationInput).toContain('color: rgb(18, 52, 86)'); + expect(state.noticeMutationInput).toContain('background-color: rgb(254, 220, 186)'); + expect(state.noticeMutationInput).toContain('text-align: center'); + expect(state.noticeMutationInput).toContain('{{ uploadError }}