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 @@ + + + diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index 694a9564..89f3f1c8 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -5,6 +5,7 @@ import PanelCard from '../components/ui/PanelCard.vue'; import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue'; import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue'; import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue'; +import AuditDiplomacyHistory from '../components/playAudit/AuditDiplomacyHistory.vue'; import AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue'; import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue'; import { usePageExit } from '../composables/usePageExit'; @@ -52,6 +53,13 @@ const appliedPolicy = computed(() => { to, }; }); +const otherNationId = ref(''); +const appliedDiplomacy = computed(() => ({ + nationId: appliedPolicy.value.nationId, + otherNationId: numeric(route.query.otherNation, 0), + from: appliedPolicy.value.from, + to: appliedPolicy.value.to, +})); const nationId = ref(''); const cityId = ref(''); const population = ref(''); @@ -124,10 +132,11 @@ const result = computed(() => : series.value ); const readQuery = () => { - tab.value = ['nations', 'generals', 'cities', 'policies'].includes(String(route.query.tab)) + tab.value = ['nations', 'generals', 'cities', 'policies', 'diplomacy'].includes(String(route.query.tab)) ? String(route.query.tab) : 'nations'; policyArea.value = appliedPolicy.value.area; + otherNationId.value = route.query.otherNation ? String(numeric(route.query.otherNation, 0)) : ''; nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : ''; cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : ''; population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population)) @@ -183,7 +192,7 @@ const load = async (append = false) => { ...response, items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items, }; - } else if (tab.value === 'policies') { + } else if (tab.value === 'policies' || tab.value === 'diplomacy') { // 정책 목록/상세는 해당 component가 필요한 요청만 실행한다. } else if (nationId.value !== '' && (moment.value === 'final' || moment.value === 'initial')) { const response = await trpc.playAudit.nationSnapshot.query({ @@ -229,6 +238,7 @@ const apply = async () => { const query = { tab: tab.value, nation: nationId.value || undefined, + otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined, city: cityId.value || undefined, population: population.value || undefined, at: moment.value, @@ -275,7 +285,9 @@ const moreNations = async () => { watch( () => JSON.stringify( - Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy') + Object.entries(route.query).filter( + ([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy' && key !== 'event' + ) ), () => { if (authorized.value) { @@ -331,14 +343,19 @@ onMounted(async () => { + +