merge: restore nation general filter menus

This commit is contained in:
2026-08-13 17:58:51 +00:00
5 changed files with 661 additions and 23 deletions
@@ -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="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('장수명 필터').fill(''); 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="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('통솔 필터').fill(''); await page.getByLabel('통솔 필터').fill('');
@@ -215,6 +215,91 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be
.not.toContain('내 보기'); .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 }) => { test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -48,6 +48,48 @@ export type NationGeneralDisplaySetting = {
export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string]; 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_KEY = 'GeneralListDisplaySetting';
export const DISPLAY_SETTINGS_VERSION = 1; export const DISPLAY_SETTINGS_VERSION = 1;
export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`; 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 => { export const matchesNumberSearch = (value: number | null, query: string): boolean => {
const normalized = query.trim(); const normalized = query.trim();
if (!normalized) return true; if (!normalized) return true;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { formatOfficerLevelText } from '../utils/nationFormat'; import { formatOfficerLevelText } from '../utils/nationFormat';
import { resolveGeneralIconUrl } from '../utils/generalIcon'; import { resolveGeneralIconUrl } from '../utils/generalIcon';
@@ -9,14 +9,18 @@ import {
compareGridValues, compareGridValues,
defaultNationGeneralDisplaySettings, defaultNationGeneralDisplaySettings,
lastUsedSettingsKey, lastUsedSettingsKey,
matchesKoreanSearch, matchesNumberFilterCondition,
matchesNumberSearch, matchesTextFilterCondition,
numberFilterOperators,
parseStoredDisplaySettings, parseStoredDisplaySettings,
parseStoredSettingKey, parseStoredSettingKey,
serializeDisplaySettings, serializeDisplaySettings,
textFilterOperators,
type NationGeneralColumnId, type NationGeneralColumnId,
type NationGeneralColumnState, type NationGeneralColumnState,
type NationGeneralDisplaySetting, type NationGeneralDisplaySetting,
type NationGeneralFilterCondition,
type NationGeneralFilterOperator,
type NationGeneralGroupId, type NationGeneralGroupId,
type NationGeneralSettingKey, type NationGeneralSettingKey,
} from '../utils/nationGeneralGrid'; } from '../utils/nationGeneralGrid';
@@ -54,6 +58,11 @@ type HeaderSegment = {
open?: boolean; open?: boolean;
}; };
type ColumnFilterState = {
join: 'AND' | 'OR';
conditions: [NationGeneralFilterCondition, NationGeneralFilterCondition];
};
const columns: ColumnDefinition[] = [ const columns: ColumnDefinition[] = [
{ id: 'icon', label: '아이콘', width: 80 }, { id: 'icon', label: '아이콘', width: 80 },
{ id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' }, { id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' },
@@ -158,7 +167,46 @@ const groupState = ref<Record<NationGeneralGroupId, boolean>>({
years: false, years: false,
killturnAndRefresh: true, killturnAndRefresh: true,
}); });
const filters = ref<Partial<Record<NationGeneralColumnId, string>>>({}); const createFilterCondition = (searchable?: 'text' | 'number'): NationGeneralFilterCondition => ({
operator: searchable === 'number' ? 'equals' : 'contains',
value: '',
valueTo: '',
});
const filters = ref(
Object.fromEntries(
columns.map((column) => [
column.id,
{
join: 'AND',
conditions: [createFilterCondition(column.searchable), createFilterCondition(column.searchable)],
},
])
) as Record<NationGeneralColumnId, ColumnFilterState>
);
const activeFilterMenu = ref<NationGeneralColumnId | null>(null);
const filterOperators = (searchable?: 'text' | 'number') =>
searchable === 'number' ? numberFilterOperators : textFilterOperators;
const isValueFreeOperator = (operator: NationGeneralFilterOperator): boolean =>
operator === 'blank' || operator === 'notBlank';
const isConditionActive = (condition: NationGeneralFilterCondition): boolean =>
isValueFreeOperator(condition.operator) || condition.value.trim() !== '';
const updateFilterOperator = (columnId: NationGeneralColumnId, conditionIndex: number, event: Event) => {
const target = event.target;
if (!(target instanceof HTMLSelectElement)) return;
const condition = filters.value[columnId].conditions[conditionIndex];
if (!condition) return;
condition.operator = target.value as NationGeneralFilterOperator;
};
const toggleFilterMenu = (columnId: NationGeneralColumnId) => {
activeFilterMenu.value = activeFilterMenu.value === columnId ? null : columnId;
};
const closeFilterMenu = () => {
activeFilterMenu.value = null;
};
const closeFilterMenuOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeFilterMenu();
};
const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => { const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => {
const cloned = cloneNationGeneralDisplaySetting(setting); const cloned = cloneNationGeneralDisplaySetting(setting);
@@ -359,14 +407,19 @@ const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue
const generals = computed(() => { const generals = computed(() => {
const filtered = [...(data.value?.generals ?? [])].filter((general) => const filtered = [...(data.value?.generals ?? [])].filter((general) =>
Object.entries(filters.value).every(([rawColumnId, query]) => { Object.entries(filters.value).every(([rawColumnId, filter]) => {
if (!query) return true;
const columnId = rawColumnId as NationGeneralColumnId; const columnId = rawColumnId as NationGeneralColumnId;
const column = columnById.get(columnId); const column = columnById.get(columnId);
if (!column?.searchable) return true;
const activeConditions = filter.conditions.filter(isConditionActive);
if (!activeConditions.length) return true;
const value = filterValue(general, columnId); const value = filterValue(general, columnId);
if (column?.searchable === 'number') const results = activeConditions.map((condition) =>
return matchesNumberSearch(typeof value === 'number' ? value : null, query); column.searchable === 'number'
return matchesKoreanSearch(value === null ? '' : String(value), query); ? matchesNumberFilterCondition(typeof value === 'number' ? value : null, condition)
: matchesTextFilterCondition(value === null ? null : String(value), condition)
);
return filter.join === 'AND' ? results.every(Boolean) : results.some(Boolean);
}) })
); );
const sorts = columnState.value const sorts = columnState.value
@@ -468,7 +521,15 @@ const cellTitle = (general: General, columnId: NationGeneralColumnId): string =>
return ''; return '';
}; };
onMounted(load); onMounted(() => {
document.addEventListener('pointerdown', closeFilterMenu);
document.addEventListener('keydown', closeFilterMenuOnEscape);
void load();
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeFilterMenu);
document.removeEventListener('keydown', closeFilterMenuOnEscape);
});
</script> </script>
<template> <template>
@@ -586,17 +647,114 @@ onMounted(load);
</th> </th>
</tr> </tr>
<tr class="filter-head"> <tr class="filter-head">
<th v-for="column in activeColumns" :key="column.id"> <th
<template v-if="column.searchable"> v-for="column in activeColumns"
:key="column.id"
:class="{ 'filter-menu-open': activeFilterMenu === column.id }"
>
<div v-if="column.searchable" class="floating-filter" @pointerdown.stop>
<input <input
v-model="filters[column.id]" v-model="filters[column.id].conditions[0].value"
type="search" type="search"
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'" :inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
:aria-label="`${column.label} 필터`" :aria-label="`${column.label} 필터`"
:placeholder="column.searchable === 'number' ? '=, >, <' : ''" placeholder=""
/> />
<span></span> <button
</template> type="button"
class="filter-menu-button"
:aria-label="`${column.label} 상세 필터 열기`"
:aria-expanded="activeFilterMenu === column.id"
title="Open Filter Menu"
@click.stop="toggleFilterMenu(column.id)"
>
<span class="filter-icon" aria-hidden="true"></span>
</button>
<div
v-if="activeFilterMenu === column.id"
class="filter-popup"
role="dialog"
:aria-label="`${column.label} 상세 필터`"
@pointerdown.stop
>
<div class="filter-condition">
<select
:value="filters[column.id].conditions[0].operator"
:aria-label="`${column.label} 번째 필터 연산자`"
@change="updateFilterOperator(column.id, 0, $event)"
>
<option
v-for="option in filterOperators(column.searchable)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<template
v-if="!isValueFreeOperator(filters[column.id].conditions[0].operator)"
>
<input
v-model="filters[column.id].conditions[0].value"
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
:aria-label="`${column.label} 번째 필터 `"
placeholder="Filter..."
/>
<input
v-if="filters[column.id].conditions[0].operator === 'inRange'"
v-model="filters[column.id].conditions[0].valueTo"
inputmode="decimal"
:aria-label="`${column.label} 번째 필터 끝값`"
placeholder="To"
/>
</template>
</div>
<template v-if="isConditionActive(filters[column.id].conditions[0])">
<div class="filter-join" role="group" :aria-label="`${column.label} 필터 결합`">
<label>
<input v-model="filters[column.id].join" type="radio" value="AND" />
AND
</label>
<label>
<input v-model="filters[column.id].join" type="radio" value="OR" />
OR
</label>
</div>
<div class="filter-condition second-condition">
<select
:value="filters[column.id].conditions[1].operator"
:aria-label="`${column.label} 번째 필터 연산자`"
@change="updateFilterOperator(column.id, 1, $event)"
>
<option
v-for="option in filterOperators(column.searchable)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<template
v-if="!isValueFreeOperator(filters[column.id].conditions[1].operator)"
>
<input
v-model="filters[column.id].conditions[1].value"
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
:aria-label="`${column.label} 번째 필터 `"
placeholder="Filter..."
/>
<input
v-if="filters[column.id].conditions[1].operator === 'inRange'"
v-model="filters[column.id].conditions[1].valueTo"
inputmode="decimal"
:aria-label="`${column.label} 번째 필터 끝값`"
placeholder="To"
/>
</template>
</div>
</template>
</div>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -853,27 +1011,139 @@ th {
font-size: 10px; font-size: 10px;
} }
.filter-head th { .filter-head th {
position: relative;
height: 32px; height: 32px;
padding: 3px 4px; padding: 3px 4px;
overflow: visible;
} }
.filter-head input { .filter-head th.filter-menu-open {
width: calc(100% - 15px); 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; height: 20px;
padding: 1px 2px;
border: 1px solid #aab3b7; border: 1px solid #aab3b7;
background: #252a2c; background: #252a2c;
color: #fff; 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; border-color: #8dd4ff;
outline: 1px solid #8dd4ff; outline: 1px solid #8dd4ff;
} }
.filter-head input::placeholder { .floating-filter > input::placeholder,
.filter-popup input::placeholder {
color: #8f999d; color: #8f999d;
font-size: 10px; font-size: 10px;
} }
.filter-head span { .filter-menu-button {
margin-left: 4px; display: inline-flex;
width: 18px;
height: 22px;
flex: 0 0 18px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
color: #a5b5bf; 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 { tbody tr {
height: 68px; height: 68px;
@@ -6,10 +6,14 @@ import {
compareGridValues, compareGridValues,
defaultNationGeneralDisplaySettings, defaultNationGeneralDisplaySettings,
matchesKoreanSearch, matchesKoreanSearch,
matchesNumberFilterCondition,
matchesNumberSearch, matchesNumberSearch,
matchesTextFilterCondition,
numberFilterOperators,
parseStoredDisplaySettings, parseStoredDisplaySettings,
parseStoredSettingKey, parseStoredSettingKey,
serializeDisplaySettings, serializeDisplaySettings,
textFilterOperators,
} from '../src/utils/nationGeneralGrid.ts'; } from '../src/utils/nationGeneralGrid.ts';
void describe('nation general Ref-compatible grid state', () => { 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(null, 2) > 0, true);
assert.equal(compareGridValues('가', '나') < 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);
});
}); });
@@ -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();
}