diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index ceb0b626..c4a95cf8 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -171,7 +171,7 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await page.getByLabel('장수명 필터').fill(''); - await page.getByLabel('통솔 필터').fill('>= 60'); + await page.getByLabel('통솔 필터').fill('70'); await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await page.getByLabel('통솔 필터').fill(''); @@ -215,6 +215,91 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be .not.toContain('내 보기'); }); +test('nation generals filter buttons open Ref operator menus and apply compound conditions', async ({ + page, +}, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + const table = page.locator('#nation-general-list'); + + const nameMenuButton = page.getByRole('button', { name: '장수명 상세 필터 열기' }); + await expect(nameMenuButton).toHaveAttribute('title', 'Open Filter Menu'); + await nameMenuButton.hover(); + expect(await nameMenuButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await nameMenuButton.focus(); + await expect(nameMenuButton).toBeFocused(); + expect(await nameMenuButton.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid'); + await nameMenuButton.click(); + + const namePopup = page.getByRole('dialog', { name: '장수명 상세 필터' }); + await expect(namePopup).toBeVisible(); + expect((await namePopup.boundingBox())?.width).toBe(190); + expect(await namePopup.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(45, 52, 54)'); + const nameOperator = page.getByLabel('장수명 첫 번째 필터 연산자'); + expect(await nameOperator.locator('option').allTextContents()).toEqual([ + 'Contains', + 'Not contains', + 'Equals', + 'Not equal', + 'Starts with', + 'Ends with', + 'Blank', + 'Not blank', + ]); + await nameOperator.selectOption('notContains'); + await page.getByLabel('장수명 첫 번째 필터 값').fill('테스트'); + await expect(page.getByRole('searchbox', { name: '장수명 필터', exact: true })).toHaveValue('테스트'); + await expect(table.locator('tr[data-general-id="1"]')).toHaveCount(0); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + + await nameOperator.selectOption('contains'); + await page.getByLabel('장수명 첫 번째 필터 값').fill('장수'); + await page.getByLabel('장수명 두 번째 필터 연산자').selectOption('notContains'); + await page.getByLabel('장수명 두 번째 필터 값').fill('테스트'); + await expect(table.locator('tr[data-general-id="1"]')).toHaveCount(0); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + await namePopup.getByLabel('OR').check(); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('core-text-filter-menu.png'), fullPage: true }); + + await page.getByLabel('장수명 두 번째 필터 값').fill(''); + await page.getByLabel('장수명 첫 번째 필터 값').fill(''); + await page.getByRole('button', { name: '통솔 상세 필터 열기' }).click(); + const numberPopup = page.getByRole('dialog', { name: '통솔 상세 필터' }); + const numberOperator = page.getByLabel('통솔 첫 번째 필터 연산자'); + expect(await numberOperator.locator('option').allTextContents()).toEqual([ + 'Equals', + 'Not equal', + 'Less than', + 'Less than or equals', + 'Greater than', + 'Greater than or equals', + 'In range', + 'Blank', + 'Not blank', + ]); + await numberOperator.selectOption('inRange'); + await page.getByLabel('통솔 첫 번째 필터 값').fill('45'); + await page.getByLabel('통솔 첫 번째 필터 끝값').fill('75'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('core-number-filter-menu.png'), fullPage: true }); + await numberOperator.selectOption('blank'); + await expect(table.locator('tr[data-general-id]')).toHaveCount(0); + await expect(numberPopup.getByPlaceholder('Filter...')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect(numberPopup).toHaveCount(0); + + await page.setViewportSize({ width: 500, height: 900 }); + expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); + await nameMenuButton.click(); + await expect(namePopup).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); +}); + test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { await install(page); await page.setViewportSize({ width: 500, height: 900 }); diff --git a/app/game-frontend/src/utils/nationGeneralGrid.ts b/app/game-frontend/src/utils/nationGeneralGrid.ts index 4cf247c3..c204d4f7 100644 --- a/app/game-frontend/src/utils/nationGeneralGrid.ts +++ b/app/game-frontend/src/utils/nationGeneralGrid.ts @@ -48,6 +48,48 @@ export type NationGeneralDisplaySetting = { export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string]; +export type TextFilterOperator = + 'contains' | 'notContains' | 'equals' | 'notEqual' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank'; +export type NumberFilterOperator = + | 'equals' + | 'notEqual' + | 'lessThan' + | 'lessThanOrEqual' + | 'greaterThan' + | 'greaterThanOrEqual' + | 'inRange' + | 'blank' + | 'notBlank'; +export type NationGeneralFilterOperator = TextFilterOperator | NumberFilterOperator; +export type NationGeneralFilterCondition = { + operator: NationGeneralFilterOperator; + value: string; + valueTo: string; +}; + +export const textFilterOperators: readonly { value: TextFilterOperator; label: string }[] = [ + { value: 'contains', label: 'Contains' }, + { value: 'notContains', label: 'Not contains' }, + { value: 'equals', label: 'Equals' }, + { value: 'notEqual', label: 'Not equal' }, + { value: 'startsWith', label: 'Starts with' }, + { value: 'endsWith', label: 'Ends with' }, + { value: 'blank', label: 'Blank' }, + { value: 'notBlank', label: 'Not blank' }, +]; + +export const numberFilterOperators: readonly { value: NumberFilterOperator; label: string }[] = [ + { value: 'equals', label: 'Equals' }, + { value: 'notEqual', label: 'Not equal' }, + { value: 'lessThan', label: 'Less than' }, + { value: 'lessThanOrEqual', label: 'Less than or equals' }, + { value: 'greaterThan', label: 'Greater than' }, + { value: 'greaterThanOrEqual', label: 'Greater than or equals' }, + { value: 'inRange', label: 'In range' }, + { value: 'blank', label: 'Blank' }, + { value: 'notBlank', label: 'Not blank' }, +]; + export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting'; export const DISPLAY_SETTINGS_VERSION = 1; export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`; @@ -269,6 +311,78 @@ export const matchesKoreanSearch = (value: string, query: string): boolean => { ); }; +const normalizedSearchValues = (value: string): [string, string] => [ + normalizeSearchText(value), + normalizeSearchText(hangulInitials(value)), +]; + +export const matchesTextFilterCondition = (value: string | null, condition: NationGeneralFilterCondition): boolean => { + const blank = value === null || value === ''; + if (condition.operator === 'blank') return blank; + if (condition.operator === 'notBlank') return !blank; + + const query = normalizeSearchText(condition.value); + if (!query) return true; + if (value === null) return false; + const candidates = normalizedSearchValues(value); + const matches = (predicate: (candidate: string) => boolean): boolean => candidates.some(predicate); + switch (condition.operator) { + case 'notContains': + return !matches((candidate) => candidate.includes(query)); + case 'equals': + return matches((candidate) => candidate === query); + case 'notEqual': + return !matches((candidate) => candidate === query); + case 'startsWith': + return matches((candidate) => candidate.startsWith(query)); + case 'endsWith': + return matches((candidate) => candidate.endsWith(query)); + default: + return matches((candidate) => candidate.includes(query)); + } +}; + +const parseFiniteNumber = (raw: string): number | null => { + const normalized = raw.trim(); + if (!normalized) return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const matchesNumberFilterCondition = ( + value: number | null, + condition: NationGeneralFilterCondition +): boolean => { + const blank = value === null || !Number.isFinite(value); + if (condition.operator === 'blank') return blank; + if (condition.operator === 'notBlank') return !blank; + if (blank) return false; + + const expected = parseFiniteNumber(condition.value); + if (expected === null) return true; + switch (condition.operator) { + case 'notEqual': + return value !== expected; + case 'lessThan': + return value < expected; + case 'lessThanOrEqual': + return value <= expected; + case 'greaterThan': + return value > expected; + case 'greaterThanOrEqual': + return value >= expected; + case 'inRange': { + const expectedTo = parseFiniteNumber(condition.valueTo); + return ( + expectedTo === null || + (value >= Math.min(expected, expectedTo) && value <= Math.max(expected, expectedTo)) + ); + } + default: + return value === expected; + } +}; + export const matchesNumberSearch = (value: number | null, query: string): boolean => { const normalized = query.trim(); if (!normalized) return true; diff --git a/app/game-frontend/src/views/NationGeneralsView.vue b/app/game-frontend/src/views/NationGeneralsView.vue index 64de7fcc..a89c52c1 100644 --- a/app/game-frontend/src/views/NationGeneralsView.vue +++ b/app/game-frontend/src/views/NationGeneralsView.vue @@ -1,5 +1,5 @@