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 @@
@@ -586,17 +647,114 @@ onMounted(load);
-
-
+ |
+
- ▽
-
+
+
+
|
|
@@ -853,27 +1011,139 @@ th {
font-size: 10px;
}
.filter-head th {
+ position: relative;
height: 32px;
padding: 3px 4px;
+ overflow: visible;
}
-.filter-head input {
- width: calc(100% - 15px);
+.filter-head th.filter-menu-open {
+ z-index: 12;
+}
+.floating-filter {
+ display: flex;
+ width: 100%;
+ height: 24px;
+ align-items: center;
+}
+.floating-filter > input {
+ width: calc(100% - 18px);
+ min-width: 0;
height: 20px;
+ padding: 1px 2px;
border: 1px solid #aab3b7;
background: #252a2c;
color: #fff;
}
-.filter-head input:focus-visible {
+.floating-filter > input:focus-visible,
+.filter-popup input:focus-visible,
+.filter-popup select:focus-visible,
+.filter-menu-button:focus-visible {
border-color: #8dd4ff;
outline: 1px solid #8dd4ff;
}
-.filter-head input::placeholder {
+.floating-filter > input::placeholder,
+.filter-popup input::placeholder {
color: #8f999d;
font-size: 10px;
}
-.filter-head span {
- margin-left: 4px;
+.filter-menu-button {
+ display: inline-flex;
+ width: 18px;
+ height: 22px;
+ flex: 0 0 18px;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
color: #a5b5bf;
+ background: transparent;
+ cursor: pointer;
+}
+.filter-menu-button:hover,
+.filter-menu-button[aria-expanded='true'] {
+ color: #fff;
+ background: #3a4144;
+}
+.filter-icon {
+ position: relative;
+ display: block;
+ width: 11px;
+ height: 10px;
+}
+.filter-icon::before {
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 0;
+ height: 0;
+ border-top: 6px solid currentcolor;
+ border-right: 5px solid transparent;
+ border-left: 5px solid transparent;
+ content: '';
+}
+.filter-icon::after {
+ position: absolute;
+ top: 6px;
+ left: 5px;
+ width: 2px;
+ height: 4px;
+ background: currentcolor;
+ content: '';
+}
+.filter-popup {
+ position: absolute;
+ z-index: 30;
+ top: 28px;
+ left: calc(100% - 19px);
+ width: 190px;
+ min-height: 60px;
+ padding: 10px;
+ border: 1px solid #596164;
+ background: #2d3436;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 45%);
+ color: #f5f5f5;
+ font-size: 12px;
+ line-height: 18px;
+ text-align: left;
+ white-space: normal;
+}
+.filter-condition {
+ display: grid;
+ gap: 6px;
+}
+.filter-condition select,
+.filter-condition input {
+ box-sizing: border-box;
+ width: 100%;
+ height: 28px;
+ padding: 2px 6px;
+ border: 1px solid #80898d;
+ border-radius: 0;
+ background: #252a2c;
+ color: #fff;
+ font: 12px/18px var(--sammo-font-sans);
+}
+.filter-condition select {
+ cursor: pointer;
+}
+.filter-join {
+ display: flex;
+ gap: 14px;
+ margin: 9px 0;
+}
+.filter-join label {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ cursor: pointer;
+}
+.filter-join input {
+ width: 13px;
+ height: 13px;
+ margin: 0;
+}
+.second-condition {
+ padding-top: 1px;
}
tbody tr {
height: 68px;
diff --git a/app/game-frontend/test/nationGeneralGrid.test.ts b/app/game-frontend/test/nationGeneralGrid.test.ts
index 0529036e..550e6b4b 100644
--- a/app/game-frontend/test/nationGeneralGrid.test.ts
+++ b/app/game-frontend/test/nationGeneralGrid.test.ts
@@ -6,10 +6,14 @@ import {
compareGridValues,
defaultNationGeneralDisplaySettings,
matchesKoreanSearch,
+ matchesNumberFilterCondition,
matchesNumberSearch,
+ matchesTextFilterCondition,
+ numberFilterOperators,
parseStoredDisplaySettings,
parseStoredSettingKey,
serializeDisplaySettings,
+ textFilterOperators,
} from '../src/utils/nationGeneralGrid.ts';
void describe('nation general Ref-compatible grid state', () => {
@@ -58,4 +62,53 @@ void describe('nation general Ref-compatible grid state', () => {
assert.equal(compareGridValues(null, 2) > 0, true);
assert.equal(compareGridValues('가', '나') < 0, true);
});
+
+ void it('exposes the Ref text and number filter menus in the same order', () => {
+ assert.deepEqual(
+ textFilterOperators.map((operator) => operator.label),
+ ['Contains', 'Not contains', 'Equals', 'Not equal', 'Starts with', 'Ends with', 'Blank', 'Not blank']
+ );
+ assert.deepEqual(
+ numberFilterOperators.map((operator) => operator.label),
+ [
+ 'Equals',
+ 'Not equal',
+ 'Less than',
+ 'Less than or equals',
+ 'Greater than',
+ 'Greater than or equals',
+ 'In range',
+ 'Blank',
+ 'Not blank',
+ ]
+ );
+ });
+
+ void it('applies Ref text operators to Korean text and initial consonants', () => {
+ assert.equal(
+ matchesTextFilterCondition('테스트장수', { operator: 'contains', value: 'ㅌㅅㅌ', valueTo: '' }),
+ true
+ );
+ assert.equal(
+ matchesTextFilterCondition('테스트장수', { operator: 'notContains', value: '다른', valueTo: '' }),
+ true
+ );
+ assert.equal(
+ matchesTextFilterCondition('테스트장수', { operator: 'startsWith', value: 'ㅌㅅ', valueTo: '' }),
+ true
+ );
+ assert.equal(matchesTextFilterCondition('', { operator: 'blank', value: '', valueTo: '' }), true);
+ assert.equal(matchesTextFilterCondition('장수', { operator: 'notBlank', value: '', valueTo: '' }), true);
+ });
+
+ void it('applies Ref number comparison, range, and blank operators', () => {
+ assert.equal(
+ matchesNumberFilterCondition(70, { operator: 'greaterThanOrEqual', value: '60', valueTo: '' }),
+ true
+ );
+ assert.equal(matchesNumberFilterCondition(70, { operator: 'inRange', value: '65', valueTo: '75' }), true);
+ assert.equal(matchesNumberFilterCondition(40, { operator: 'inRange', value: '65', valueTo: '75' }), false);
+ assert.equal(matchesNumberFilterCondition(null, { operator: 'blank', value: '', valueTo: '' }), true);
+ assert.equal(matchesNumberFilterCondition(0, { operator: 'notBlank', value: '', valueTo: '' }), true);
+ });
});
diff --git a/tools/frontend-legacy-parity/reference-nation-general-filter-menu.mjs b/tools/frontend-legacy-parity/reference-nation-general-filter-menu.mjs
new file mode 100644
index 00000000..89d88d15
--- /dev/null
+++ b/tools/frontend-legacy-parity/reference-nation-general-filter-menu.mjs
@@ -0,0 +1,116 @@
+import { chromium } from '@playwright/test';
+import { createHash } from 'node:crypto';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+
+const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3416/sam/';
+const username = process.env.REF_GENERAL_USER ?? 's100user01';
+const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
+const artifactRoot = resolve(
+ process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-nation-general-filter-menu'
+);
+
+if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
+const password = (await readFile(passwordFile, 'utf8')).trim();
+await mkdir(artifactRoot, { recursive: true });
+
+const inspectVisiblePopup = (page) =>
+ page.evaluate(() => {
+ const popup = [...document.querySelectorAll('.ag-popup, .ag-popup-child')].find((element) => {
+ const rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ });
+ if (!(popup instanceof HTMLElement)) return null;
+ const rect = popup.getBoundingClientRect();
+ const style = getComputedStyle(popup);
+ return {
+ html: popup.innerHTML,
+ text: popup.innerText,
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
+ style: {
+ color: style.color,
+ backgroundColor: style.backgroundColor,
+ fontFamily: style.fontFamily,
+ fontSize: style.fontSize,
+ },
+ inputs: [...popup.querySelectorAll('input')].map((input) => ({
+ type: input.type,
+ value: input.value,
+ placeholder: input.placeholder,
+ ariaLabel: input.getAttribute('aria-label'),
+ })),
+ selects: [...popup.querySelectorAll('select')].map((select) => ({
+ value: select.value,
+ options: [...select.options].map((option) => ({ value: option.value, text: option.text })),
+ })),
+ buttons: [...popup.querySelectorAll('button')].map((button) => ({
+ text: button.innerText,
+ ariaLabel: button.getAttribute('aria-label'),
+ title: button.title,
+ className: button.className,
+ })),
+ };
+ });
+
+const inspectOperatorOptions = async (page) => {
+ const popup = page.locator('.ag-popup-child:visible');
+ await popup.getByRole('listbox', { name: 'Filtering operator' }).first().click();
+ const optionList = page.locator('.ag-select-list:visible');
+ await optionList.waitFor();
+ const options = await optionList.locator('.ag-list-item').allInnerTexts();
+ await page.keyboard.press('Escape');
+ return options;
+};
+
+const browser = await chromium.launch({ headless: true });
+try {
+ const context = await browser.newContext({
+ viewport: { width: 1200, height: 900 },
+ deviceScaleFactor: 1,
+ locale: 'ko-KR',
+ colorScheme: 'dark',
+ });
+ const page = await context.newPage();
+ await page.goto(baseUrl, { waitUntil: 'networkidle' });
+ const salt = await page.locator('#global_salt').inputValue();
+ const passwordHash = createHash('sha512')
+ .update(salt + password + salt)
+ .digest('hex');
+ const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
+ data: { username, password: passwordHash },
+ });
+ const loginResult = await login.json();
+ if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
+
+ await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
+ await page.goto(new URL('hwe/v_nationGeneral.php', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
+ await page.locator('.ag-root-wrapper').waitFor();
+ await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
+
+ const output = {};
+ const nameFilterCell = page.locator('.ag-header-row-column-filter .ag-header-cell').nth(1);
+ output.nameCell = await nameFilterCell.evaluate((element) => ({
+ html: element.innerHTML,
+ buttons: [...element.querySelectorAll('button')].map((button) => ({
+ ariaLabel: button.getAttribute('aria-label'),
+ title: button.title,
+ className: button.className,
+ })),
+ }));
+ await nameFilterCell.locator('button').click();
+ output.namePopup = await inspectVisiblePopup(page);
+ output.nameOperators = await inspectOperatorOptions(page);
+ await page.screenshot({ path: resolve(artifactRoot, 'ref-name-filter-menu.png'), fullPage: true });
+ await page.keyboard.press('Escape');
+
+ const leadershipFilterButton = page.locator('.ag-floating-filter-button-button:visible').nth(4);
+ await leadershipFilterButton.click();
+ output.numberPopup = await inspectVisiblePopup(page);
+ output.numberOperators = await inspectOperatorOptions(page);
+ await page.screenshot({ path: resolve(artifactRoot, 'ref-number-filter-menu.png'), fullPage: true });
+
+ await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
+ process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
+} finally {
+ await browser.close();
+}