diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index f23fa20f..ec8b45af 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -154,6 +154,8 @@ export const playAuditRouter = router({ zAuditPage.extend({ cityId: z.number().int().nonnegative().optional(), population: z.enum(['human', 'npc', 'troopNpc']).optional(), + name: z.string().trim().max(64).optional(), + order: z.enum(['asc', 'desc']).default('asc'), }) ) .query(({ ctx, input }) => @@ -168,6 +170,14 @@ export const playAuditRouter = router({ ? 5 : undefined; const filter = { nationId: input.nationId, cityId: input.cityId, npcState }; + // LIKE wildcard도 이름의 문자로 취급한다. 과거 검색은 선택한 표본 안에서만 수행한다. + const name = input.name?.replace(/[\\%_]/g, '\\$&'); + const idRange = + input.cursor === undefined + ? undefined + : input.order === 'desc' + ? { lt: input.cursor } + : { gt: input.cursor }; if (input.at) { const sample = await findAuditMonth(tx, world, input.at); const rows = sample @@ -175,9 +185,10 @@ export const playAuditRouter = router({ where: { sampleId: sample.id, ...filter, - generalId: input.cursor === undefined ? undefined : { gt: input.cursor }, + generalId: idRange, + data: name ? { path: ['name'], string_contains: name } : undefined, }, - orderBy: { generalId: 'asc' }, + orderBy: { generalId: input.order }, take: input.limit + 1, select: { data: true }, }) @@ -194,8 +205,8 @@ export const playAuditRouter = router({ }; } const rows = await tx.general.findMany({ - where: { ...filter, id: input.cursor === undefined ? undefined : { gt: input.cursor } }, - orderBy: { id: 'asc' }, + where: { ...filter, id: idRange, name: name ? { contains: name } : undefined }, + orderBy: { id: input.order }, take: input.limit + 1, select: generalSelect, }); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 82fc95bf..88e595c8 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2628,6 +2628,43 @@ integration('game API security over HTTP transport', () => { result: { data: { collected: true, items: [{ name: '과거이름' }] } }, }); expect(JSON.stringify(history.body)).not.toContain('must-not-expose'); + for (const name of ['과거', ' 과거 ']) { + expect((await get('generals', admin, { at: { year: 190, month: 1 }, name })).body).toMatchObject({ + result: { data: { items: [{ name: '과거이름' }] } }, + }); + } + for (const name of ['없는이름', '%', '_', '\\']) { + expect((await get('generals', admin, { at: { year: 190, month: 1 }, name })).body).toMatchObject({ + result: { data: { items: [] } }, + }); + } + const currentName = (await db.general.findUniqueOrThrow({ where: { id: generalId } })).name; + expect((await get('generals', admin, { name: currentName })).body).toMatchObject({ + result: { data: { items: expect.arrayContaining([expect.objectContaining({ id: generalId })]) } }, + }); + expect((await get('generals', admin, { name: '과거이름' })).body).toMatchObject({ + result: { data: { items: [] } }, + }); + expect((await get('generals', admin, { name: '%' })).body).toMatchObject({ + result: { data: { items: [] } }, + }); + const descendingIds = await db.general.findMany({ orderBy: { id: 'desc' }, select: { id: true } }); + expect((await get('generals', admin, { order: 'desc', limit: 1 })).body).toMatchObject({ + result: { data: { nextCursor: descendingIds[0]!.id, items: [descendingIds[0]] } }, + }); + expect( + (await get('generals', admin, { order: 'desc', limit: 1, cursor: descendingIds[0]!.id })).body + ).toMatchObject({ + result: { data: { items: [descendingIds[1]] } }, + }); + expect( + (await get('generals', admin, { order: 'desc', at: { year: 190, month: 1 }, cursor: generalId })).body + ).toMatchObject({ + result: { data: { items: [] } }, + }); + expect((await get('generals', admin, { name: '가'.repeat(65) })).status).toBe(400); + expect((await get('generals', admin, { order: 'gold' })).status).toBe(400); + expect((await get('generals', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({ result: { data: { collected: false, items: [] } }, }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index e5d7d40a..ae2663b1 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -982,3 +982,36 @@ for (const [kind, label, value] of [ await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음'); }); } + +test('general search is explicit and persists across pagination and reload', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6')); + await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible(); + const count = requests.filter((r) => r.operation === 'playAudit.generals').length; + await page.getByLabel('장수 이름', { exact: true }).fill('감사'); + await page.getByLabel('장수 번호 정렬', { exact: true }).selectOption('desc'); + expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(count); + await page.getByRole('button', { name: '조회', exact: true }).click(); + await expect(page).toHaveURL(/name=/); + await expect + .poll(() => requests.filter((r) => r.operation === 'playAudit.generals').at(-1)?.input) + .toMatchObject({ + name: '감사', + order: 'desc', + at: { year: 190, month: 6, kind: 'MONTH_END' }, + }); + await page.getByRole('button', { name: '다음 50개 불러오기' }).click(); + await expect + .poll(() => requests.filter((r) => r.operation === 'playAudit.generals').at(-1)?.input) + .toMatchObject({ + name: '감사', + order: 'desc', + cursor: 1, + }); + await page.reload(); + await expect(page.getByLabel('장수 이름', { exact: true })).toHaveValue('감사'); + await expect(page.getByLabel('장수 번호 정렬', { exact: true })).toHaveValue('desc'); + await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible(); + await capture(page, 'mobile-general-search'); +}); diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index 89f3f1c8..0611370e 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -63,6 +63,8 @@ const appliedDiplomacy = computed(() => ({ const nationId = ref(''); const cityId = ref(''); const population = ref(''); +const generalName = ref(''); +const generalOrder = ref<'asc' | 'desc'>('asc'); const moment = ref('current'); const year = ref(0); const month = ref(1); @@ -139,6 +141,8 @@ const readQuery = () => { 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)) : ''; + generalName.value = typeof route.query.name === 'string' ? route.query.name : ''; + generalOrder.value = route.query.order === 'desc' ? 'desc' : 'asc'; population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population)) ? String(route.query.population) : ''; @@ -171,6 +175,8 @@ const load = async (append = false) => { const response = await trpc.playAudit.generals.query({ ...filter, cityId: cityId.value === '' ? undefined : Number(cityId.value), + name: generalName.value.trim() || undefined, + order: generalOrder.value, population: population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc' ? population.value @@ -241,6 +247,8 @@ const apply = async () => { otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined, city: cityId.value || undefined, population: population.value || undefined, + name: tab.value === 'generals' ? generalName.value.trim() || undefined : undefined, + order: tab.value === 'generals' ? generalOrder.value : undefined, at: moment.value, year: String(year.value), month: String(month.value), @@ -269,6 +277,8 @@ const showCityGenerals = async (id: number) => { cityId.value = String(id); nationId.value = ''; population.value = ''; + generalName.value = ''; + generalOrder.value = 'asc'; await apply(); }; const moreNations = async () => { @@ -468,6 +478,19 @@ onMounted(async () => {