diff --git a/app/game-api/src/router/playAudit/diplomacy.ts b/app/game-api/src/router/playAudit/diplomacy.ts index 9e4a2904..6a92a1a4 100644 --- a/app/game-api/src/router/playAudit/diplomacy.ts +++ b/app/game-api/src/router/playAudit/diplomacy.ts @@ -1,3 +1,4 @@ +import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { hashAuditDiplomacyDocument, type GamePrisma } from '@sammo-ts/infra'; @@ -204,6 +205,8 @@ export const diplomacyEvent = auditProcedure.input(z.object({ id: zId }).strict( id: document.id, brief: document.textBrief, detail: document.textDetail, + briefHtml: purifyDiplomacyHtml(document.textBrief), + detailHtml: purifyDiplomacyHtml(document.textDetail), writtenAt: document.date, } : null, diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 82d20fe9..f861274f 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2255,7 +2255,7 @@ integration('game API security over HTTP transport', () => { srcSignerId: generalId, state: 'CANCELLED', textBrief: '당시 외교 문서', - textDetail: '
역사 본문
', + textDetail: '역사 본문
', }, }); await db.playAuditDiplomacyEvent.createMany({ @@ -2338,7 +2338,10 @@ integration('game API security over HTTP transport', () => { after: { state: 'ACTIVATED' }, inputSequence: '9007199254740993', documentStatus: 'AVAILABLE', - document: { detail: '역사 본문
' }, + document: { + detail: '역사 본문
', + detailHtml: '역사 본문
', + }, }, }, }, diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index c32a6448..71b25424 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -89,6 +89,79 @@ const install = async (page: Page, denied = false) => { ], nextCursor: null, }); + case 'playAudit.diplomacyHistory': + return result({ + ...world, + coverage: 'RECORDED_EVENTS_ONLY', + nextCursor: input.cursor ? null : '9007199254741001', + items: [ + { + id: (input.cursor ? 'e' : 'd').repeat(64), + sequence: input.cursor ? '9007199254741000' : '9007199254741001', + srcNationId: 2, + destNationId: 3, + category: 'DOCUMENT', + source: 'API', + eventType: input.cursor ? 'LETTER_DESTROYED' : 'LETTER_ACCEPTED', + documentId: 8, + previousDocumentId: 7, + year: 190, + month: 6, + actor: { + generalId: 1, + name: '당시 외교권자', + nationId: 2, + officerLevel: 12, + npcState: 0, + }, + createdAt: world.asOf, + }, + ], + }); + case 'playAudit.diplomacyEvent': + return result({ + ...world, + event: { + id: input.id, + sequence: '9007199254741001', + srcNationId: 2, + destNationId: 3, + category: 'DOCUMENT', + source: 'API', + eventType: 'LETTER_ACCEPTED', + documentId: 8, + previousDocumentId: 7, + year: 190, + month: 6, + actor: { + generalId: 1, + name: '당시 외교권자', + nationId: 2, + officerLevel: 12, + npcState: 0, + }, + createdAt: world.asOf, + before: { state: 'PROPOSED' }, + after: { state: 'ACTIVATED' }, + tick: '100', + clockRevision: '1', + ordinal: 1, + executionId: 'fixture', + requestId: 'fixture-request', + inputSequence: '9007199254740993', + documentStatus: String(input.id).startsWith('e') ? 'HASH_MISMATCH' : 'AVAILABLE', + document: String(input.id).startsWith('e') + ? null + : { + id: 8, + writtenAt: world.asOf, + brief: '협정', + briefHtml: '협정
', + detail: '서명된 내용
', + detailHtml: '서명된 내용
', + }, + }, + }); case 'playAudit.policyHistory': return result({ ...world, @@ -500,6 +573,7 @@ test('general logs load explicitly and cache each category without reloading ent await page.goto(gamePath('/play-audit?tab=generals&general=1')); await expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible(); expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(0); + await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible(); const listCount = requests.filter((r) => r.operation === 'playAudit.generals').length; await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click(); await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible(); @@ -711,3 +785,81 @@ test('initial observation is separate from month-end and final snapshots', async await page.setViewportSize({ width: 390, height: 844 }); await capture(page, 'mobile-initial-observation'); }); + +test('diplomacy history loads selected safe document details without reloading the list', async ({ page }) => { + const requests = await install(page); + await page.goto( + gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6') + ); + await expect(page.getByRole('button', { name: '문서 승인', exact: true })).toBeVisible(); + expect(requests.some(({ operation }) => operation === 'playAudit.diplomacyEvent')).toBe(false); + const count = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length; + const nations = requests.filter(({ operation }) => operation === 'playAudit.nations').length; + await page.getByRole('button', { name: '문서 승인', exact: true }).click(); + await expect( + page.getByRole('region', { name: '당시 외교 문서' }).getByText('서명된 내용', { exact: true }) + ).toBeVisible(); + expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count); + expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nations); + await page.getByText('원문 HTML 확인', { exact: true }).click(); + await expect(page.locator('pre').filter({ hasText: '' })).toBeVisible(); + expect(await page.evaluate(() => Reflect.get(window, 'auditInjected'))).toBeUndefined(); + await capture(page, 'desktop-diplomacy-detail'); + await page.reload(); + await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await capture(page, 'mobile-diplomacy-detail'); + const comparison = page.getByLabel('외교 전후 값', { exact: true }); + await comparison.hover(); + await page.mouse.wheel(650, 0); + await expect.poll(() => comparison.evaluate((node) => node.scrollLeft)).toBeGreaterThan(0); + await page.getByRole('button', { name: '외교 상세 닫기' }).click(); + await page.getByRole('button', { name: '다음 사건 50개' }).click(); + await page.getByRole('button', { name: '문서 파기', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('해시와 달라'); + await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0); + const before = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length; + await page.getByLabel('시작 월', { exact: true }).fill('2'); + expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(before); + await page.getByRole('button', { name: '조회', exact: true }).click(); + await expect + .poll(() => requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length) + .toBeGreaterThan(before); + expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').at(-1)?.input.from).toEqual({ + year: 190, + month: 2, + }); +}); + +test('diplomacy detail failure retries independently', async ({ page }) => { + const requests = await install(page); + let fail = true; + await page.route(gameTrpcRoute, async (route) => { + if (fail && route.request().url().includes('playAudit.diplomacyEvent')) { + fail = false; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: '외교 상세 재시도', + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 }, + }, + }, + ]), + }); + } else await route.fallback(); + }); + await page.goto( + gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6') + ); + await expect(page.getByRole('button', { name: '문서 승인', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '문서 승인', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('외교 상세 재시도'); + const count = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length; + await page.getByRole('button', { name: '상세 다시 조회' }).click(); + await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible(); + expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue new file mode 100644 index 00000000..311ca690 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue @@ -0,0 +1,296 @@ + + +기록된 문서와 관계 변경입니다. 수집 이전의 상태와 기록 사이의 미관측 변경은 복원하지 않습니다.
+외교 이력 조회 중…
+{{ error }}
+ +기수 식별자가 없어 외교 이력을 조회할 수 없습니다.
+선택한 기간에 기록된 외교 사건이 없습니다.
+| 사건 | +게임 시각 | +방향 | +당시 주체 | +
|---|---|---|---|
|
+ 순번 {{ item.sequence }} + |
+ {{ item.year }}년 {{ item.month }}월 | +국가 #{{ item.srcNationId }} → #{{ item.destNationId }} | ++ {{ + item.actor + ? `${item.actor.name} (#${item.actor.generalId}) · 직책 ${item.actor.officerLevel}` + : '월간 처리 / 관측 기준' + }} + | +
외교 상세 조회 중…
++ {{ detailError }} +
+ ++ {{ labels[detail.event.eventType] ?? detail.event.eventType }} · {{ detail.event.year }}년 + {{ detail.event.month }}월 · 순번 {{ detail.event.sequence }} +
+기록 시각 {{ detail.event.createdAt }}
+이전 문서 #{{ detail.event.previousDocumentId }}
++ {{ detail.event.actor.name }} (#{{ detail.event.actor.generalId }}) · 당시 국가 #{{ + detail.event.actor.nationId + }} + · 직책 {{ detail.event.actor.officerLevel }} + · {{ detail.event.actor.actionKey }} +
+| 항목 | +변경 전 | +변경 후 | +
|---|---|---|
| {{ fieldLabels[field.key] ?? field.key }} | +{{ valueText(field.key, field.before) }} | +{{ valueText(field.key, field.after) }} | +
+ 참조 문서가 없어 원문을 표시할 수 없습니다. +
++ 원문이 기록 당시 해시와 달라 표시를 중단했습니다. +
+작성 시각 {{ detail.event.document.writtenAt }}
+ + + +{{ detail.event.document.brief }}
+ {{ detail.event.document.detail }}
+ + tick {{ detail.event.tick ?? '미상' }} · 시계 버전 {{ detail.event.clockRevision ?? '미상' }} · + 실행 {{ detail.event.executionId }} +
++ 요청 {{ detail.event.requestId ?? '해당 없음' }} · 입력 순번 + {{ detail.event.inputSequence ?? '해당 없음' }} +
+