Merge remote-tracking branch 'origin/main' into fix/map-city-hover-details-20260813
This commit is contained in:
@@ -28,7 +28,20 @@ import {
|
|||||||
sanitizeInternalDisplayCode,
|
sanitizeInternalDisplayCode,
|
||||||
} from '../../services/gameDisplayNames.js';
|
} from '../../services/gameDisplayNames.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
|
import {
|
||||||
|
loadTraitNames,
|
||||||
|
resolveNationBill,
|
||||||
|
resolveNationBlockScout,
|
||||||
|
resolveNationBlockWar,
|
||||||
|
resolveNationNotice,
|
||||||
|
resolveNationRate,
|
||||||
|
type TraitNameMap,
|
||||||
|
} from '../nation/shared.js';
|
||||||
|
import {
|
||||||
|
resolveImpossibleStrategicCommands,
|
||||||
|
resolveMainNationTech,
|
||||||
|
splitNationTraitInfo,
|
||||||
|
} from '../../services/mainNationProjection.js';
|
||||||
|
|
||||||
const zGeneralSettings = z.object({
|
const zGeneralSettings = z.object({
|
||||||
tnmt: z.number().int().optional(),
|
tnmt: z.number().int().optional(),
|
||||||
@@ -54,6 +67,7 @@ const NEUTRAL_NATION_CONTEXT = {
|
|||||||
tech: 0,
|
tech: 0,
|
||||||
typeCode: 'None',
|
typeCode: 'None',
|
||||||
capitalCityId: null,
|
capitalCityId: null,
|
||||||
|
meta: {},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const resolveImmediateActionRequestId = (
|
const resolveImmediateActionRequestId = (
|
||||||
@@ -296,20 +310,42 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
tech: true,
|
tech: true,
|
||||||
typeCode: true,
|
typeCode: true,
|
||||||
capitalCityId: true,
|
capitalCityId: true,
|
||||||
|
meta: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
ctx.db.worldState.findFirst({ select: { currentYear: true, currentMonth: true, config: true, meta: true } }),
|
||||||
]);
|
]);
|
||||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||||
|
|
||||||
const [capitalCity, cityNation] = await Promise.all([
|
const [capitalCity, cityNation, nationPopulation, nationCrew, topChiefRows] = await Promise.all([
|
||||||
nation.capitalCityId
|
nation.capitalCityId
|
||||||
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
|
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
city && city.nationId > 0
|
city && city.nationId > 0
|
||||||
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
|
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
nation.id > 0
|
||||||
|
? ctx.db.city.aggregate({
|
||||||
|
where: { nationId: nation.id },
|
||||||
|
_count: true,
|
||||||
|
_sum: { population: true, populationMax: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }),
|
||||||
|
nation.id > 0
|
||||||
|
? ctx.db.general.aggregate({
|
||||||
|
where: { nationId: nation.id, npcState: { not: 5 } },
|
||||||
|
_count: true,
|
||||||
|
_sum: { crew: true, leadership: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }),
|
||||||
|
nation.id > 0
|
||||||
|
? ctx.db.general.findMany({
|
||||||
|
where: { nationId: nation.id, officerLevel: { gte: 11 } },
|
||||||
|
select: { id: true, name: true, npcState: true, officerLevel: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
|
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
|
||||||
loadTraitNames([general.personalCode], 'personality'),
|
loadTraitNames([general.personalCode], 'personality'),
|
||||||
@@ -327,6 +363,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
const settings = resolveUserSettings(metaRecord);
|
const settings = resolveUserSettings(metaRecord);
|
||||||
const penalties = resolvePenalty(general.penalty);
|
const penalties = resolvePenalty(general.penalty);
|
||||||
const dedicationLevel = readNumber(metaRecord.dedlevel, 0);
|
const dedicationLevel = readNumber(metaRecord.dedlevel, 0);
|
||||||
|
const nationMeta = asRecord(nation.meta);
|
||||||
|
const nationType = nationTypeNames.get(nation.typeCode);
|
||||||
|
const nationTypeEffects = splitNationTraitInfo(nationType?.info ?? '');
|
||||||
|
const nationTech = resolveMainNationTech({
|
||||||
|
tech: nation.tech,
|
||||||
|
currentYear: worldState?.currentYear ?? 0,
|
||||||
|
worldConfig: worldState?.config,
|
||||||
|
worldMeta: worldState?.meta,
|
||||||
|
});
|
||||||
|
const topChiefs = Object.fromEntries(
|
||||||
|
topChiefRows.map((chief) => [chief.officerLevel, { id: chief.id, name: chief.name, npcState: chief.npcState }])
|
||||||
|
);
|
||||||
const itemName = (code: string | null): string | null => {
|
const itemName = (code: string | null): string | null => {
|
||||||
const normalized = normalizeItemCode(code);
|
const normalized = normalizeItemCode(code);
|
||||||
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
|
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
|
||||||
@@ -406,13 +454,48 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
nation: {
|
nation: {
|
||||||
...nation,
|
id: nation.id,
|
||||||
|
name: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
level: nation.level,
|
||||||
|
gold: nation.gold,
|
||||||
|
rice: nation.rice,
|
||||||
|
tech: nation.tech,
|
||||||
|
typeCode: nation.typeCode,
|
||||||
|
capitalCityId: nation.capitalCityId,
|
||||||
levelName: resolveNationLevelName(nation.level),
|
levelName: resolveNationLevelName(nation.level),
|
||||||
typeName:
|
typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
||||||
nation.id === 0
|
typePros: nationTypeEffects.pros,
|
||||||
? '해당 없음'
|
typeCons: nationTypeEffects.cons,
|
||||||
: (nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
|
||||||
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
|
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
|
||||||
|
population: {
|
||||||
|
cityCount: nationPopulation._count,
|
||||||
|
current: nationPopulation._sum.population ?? 0,
|
||||||
|
max: nationPopulation._sum.populationMax ?? 0,
|
||||||
|
},
|
||||||
|
crew: {
|
||||||
|
generalCount: nationCrew._count,
|
||||||
|
current: nationCrew._sum.crew ?? 0,
|
||||||
|
max: (nationCrew._sum.leadership ?? 0) * 100,
|
||||||
|
},
|
||||||
|
power: readNumber(nationMeta.power, 0),
|
||||||
|
bill: resolveNationBill(nationMeta),
|
||||||
|
taxRate: resolveNationRate(nation),
|
||||||
|
strategicCommandLimit: readNumber(nationMeta.strategic_cmd_limit, 0),
|
||||||
|
diplomaticLimit: readNumber(nationMeta.surlimit, 0),
|
||||||
|
prohibitScout: resolveNationBlockScout(nationMeta),
|
||||||
|
prohibitWar: resolveNationBlockWar(nationMeta),
|
||||||
|
techLevel: nationTech.level,
|
||||||
|
techLimited: nationTech.limited,
|
||||||
|
topChiefs,
|
||||||
|
impossibleStrategicCommands:
|
||||||
|
nation.id === 0
|
||||||
|
? []
|
||||||
|
: resolveImpossibleStrategicCommands(
|
||||||
|
nationMeta,
|
||||||
|
worldState?.currentYear ?? 0,
|
||||||
|
worldState?.currentMonth ?? 1
|
||||||
|
),
|
||||||
},
|
},
|
||||||
settings,
|
settings,
|
||||||
penalties,
|
penalties,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
const STRATEGIC_COMMAND_NAMES = [
|
||||||
|
'필사즉생',
|
||||||
|
'백성동원',
|
||||||
|
'수몰',
|
||||||
|
'허보',
|
||||||
|
'의병모집',
|
||||||
|
'이호경식',
|
||||||
|
'급습',
|
||||||
|
'피장파장',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const readFiniteNumber = (value: unknown, fallback = 0): number => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
export const splitNationTraitInfo = (info: string): { pros: string; cons: string } => {
|
||||||
|
const tokens = info.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
return {
|
||||||
|
pros: tokens.filter((token) => token.endsWith('↑')).join(' '),
|
||||||
|
cons: tokens.filter((token) => token.endsWith('↓')).join(' '),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveMainNationTech = (options: {
|
||||||
|
tech: number;
|
||||||
|
currentYear: number;
|
||||||
|
worldConfig: unknown;
|
||||||
|
worldMeta: unknown;
|
||||||
|
}): { level: number; limited: boolean } => {
|
||||||
|
const config = asRecord(options.worldConfig);
|
||||||
|
const constValues = asRecord(config.const ?? config.consts);
|
||||||
|
const scenarioMeta = asRecord(asRecord(options.worldMeta).scenarioMeta);
|
||||||
|
const maxLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.maxTechLevel, 12)));
|
||||||
|
const initialLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.initialAllowedTechLevel, 1)));
|
||||||
|
const increaseYears = Math.max(1, Math.floor(readFiniteNumber(constValues.techLevelIncYear, 5)));
|
||||||
|
const startYear = readFiniteNumber(scenarioMeta.startYear, options.currentYear);
|
||||||
|
const relativeMaximum = clamp(
|
||||||
|
Math.floor((options.currentYear - startYear) / increaseYears) + initialLevel,
|
||||||
|
1,
|
||||||
|
maxLevel
|
||||||
|
);
|
||||||
|
const level = clamp(Math.floor(options.tech / 1000), 0, maxLevel);
|
||||||
|
return { level, limited: level >= relativeMaximum };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveImpossibleStrategicCommands = (
|
||||||
|
nationMeta: unknown,
|
||||||
|
currentYear: number,
|
||||||
|
currentMonth: number
|
||||||
|
): Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> => {
|
||||||
|
const meta = asRecord(nationMeta);
|
||||||
|
const currentYearMonth = Math.floor(currentYear) * 12 + Math.floor(currentMonth) - 1;
|
||||||
|
const result: Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> = [];
|
||||||
|
|
||||||
|
for (const name of STRATEGIC_COMMAND_NAMES) {
|
||||||
|
const nextAvailable = Math.floor(readFiniteNumber(meta[`next_execute_${name}`], 0));
|
||||||
|
if (nextAvailable <= currentYearMonth) continue;
|
||||||
|
result.push({
|
||||||
|
name,
|
||||||
|
remainingTurns: nextAvailable - currentYearMonth,
|
||||||
|
availableYear: Math.floor(nextAvailable / 12),
|
||||||
|
availableMonth: (nextAvailable % 12) + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveImpossibleStrategicCommands,
|
||||||
|
resolveMainNationTech,
|
||||||
|
splitNationTraitInfo,
|
||||||
|
} from '../src/services/mainNationProjection.js';
|
||||||
|
|
||||||
|
describe('main nation projection', () => {
|
||||||
|
it('splits the Ref nation-type advantages and disadvantages without changing their order', () => {
|
||||||
|
expect(splitNationTraitInfo('농상↑ 민심↑ 쌀수입↓')).toEqual({
|
||||||
|
pros: '농상↑ 민심↑',
|
||||||
|
cons: '쌀수입↓',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the scenario-relative Ref technology grade and limit', () => {
|
||||||
|
expect(
|
||||||
|
resolveMainNationTech({
|
||||||
|
tech: 3_999,
|
||||||
|
currentYear: 190,
|
||||||
|
worldConfig: {
|
||||||
|
const: { maxTechLevel: 12, initialAllowedTechLevel: 1, techLevelIncYear: 5 },
|
||||||
|
},
|
||||||
|
worldMeta: { scenarioMeta: { startYear: 180 } },
|
||||||
|
})
|
||||||
|
).toEqual({ level: 3, limited: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only strategic commands whose Ref-compatible cooldown is still active', () => {
|
||||||
|
expect(
|
||||||
|
resolveImpossibleStrategicCommands(
|
||||||
|
{
|
||||||
|
next_execute_수몰: 190 * 12 + 4,
|
||||||
|
next_execute_허보: 190 * 12 + 2,
|
||||||
|
},
|
||||||
|
190,
|
||||||
|
4
|
||||||
|
)
|
||||||
|
).toEqual([{ name: '수몰', remainingTurns: 1, availableYear: 190, availableMonth: 5 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -116,7 +116,31 @@ const generalContext = {
|
|||||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||||
},
|
},
|
||||||
city,
|
city,
|
||||||
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
nation: {
|
||||||
|
id: 1,
|
||||||
|
name: '아국',
|
||||||
|
color: '#008000',
|
||||||
|
level: 1,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 9_000,
|
||||||
|
tech: 100,
|
||||||
|
typeName: '유가',
|
||||||
|
typePros: '농상↑ 민심↑',
|
||||||
|
typeCons: '쌀수입↓',
|
||||||
|
population: { cityCount: 1, current: 150_000, max: 620_500 },
|
||||||
|
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||||
|
power: 1_234,
|
||||||
|
bill: 100,
|
||||||
|
taxRate: 20,
|
||||||
|
strategicCommandLimit: 0,
|
||||||
|
diplomaticLimit: 0,
|
||||||
|
prohibitScout: false,
|
||||||
|
prohibitWar: false,
|
||||||
|
techLevel: 0,
|
||||||
|
techLimited: false,
|
||||||
|
topChiefs: {},
|
||||||
|
impossibleStrategicCommands: [],
|
||||||
|
},
|
||||||
settings: {},
|
settings: {},
|
||||||
penalties: {},
|
penalties: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,9 +145,24 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
rice: 0,
|
rice: 0,
|
||||||
tech: 0,
|
tech: 0,
|
||||||
typeCode: 'None',
|
typeCode: 'None',
|
||||||
typeName: '해당 없음',
|
typeName: '-',
|
||||||
|
typePros: '',
|
||||||
|
typeCons: '',
|
||||||
capitalCityId: null,
|
capitalCityId: null,
|
||||||
capitalCityName: null,
|
capitalCityName: null,
|
||||||
|
population: { cityCount: 0, current: 0, max: 0 },
|
||||||
|
crew: { generalCount: 0, current: 0, max: 0 },
|
||||||
|
power: 0,
|
||||||
|
bill: 100,
|
||||||
|
taxRate: 20,
|
||||||
|
strategicCommandLimit: 0,
|
||||||
|
diplomaticLimit: 0,
|
||||||
|
prohibitScout: false,
|
||||||
|
prohibitWar: false,
|
||||||
|
techLevel: 0,
|
||||||
|
techLimited: false,
|
||||||
|
topChiefs: {},
|
||||||
|
impossibleStrategicCommands: [],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -162,6 +177,21 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
typeName: '법가',
|
typeName: '법가',
|
||||||
capitalCityId: 1,
|
capitalCityId: 1,
|
||||||
capitalCityName: '업',
|
capitalCityName: '업',
|
||||||
|
typePros: '금수입↑ 치안↑',
|
||||||
|
typeCons: '인구↓ 민심↓',
|
||||||
|
population: { cityCount: 1, current: 1_000, max: 2_000 },
|
||||||
|
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||||
|
power: 1_234,
|
||||||
|
bill: 100,
|
||||||
|
taxRate: 20,
|
||||||
|
strategicCommandLimit: 0,
|
||||||
|
diplomaticLimit: 0,
|
||||||
|
prohibitScout: false,
|
||||||
|
prohibitWar: false,
|
||||||
|
techLevel: 0,
|
||||||
|
techLimited: false,
|
||||||
|
topChiefs: {},
|
||||||
|
impossibleStrategicCommands: [],
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
tnmt: 0,
|
tnmt: 0,
|
||||||
|
|||||||
@@ -261,6 +261,24 @@ const generalContext = (state: NavigationFixture) => ({
|
|||||||
bill: 100,
|
bill: 100,
|
||||||
capitalCityId: 1,
|
capitalCityId: 1,
|
||||||
typeCode: 'che_유가',
|
typeCode: 'che_유가',
|
||||||
|
typeName: '유가',
|
||||||
|
typePros: '농상↑ 민심↑',
|
||||||
|
typeCons: '쌀수입↓',
|
||||||
|
population: { cityCount: 2, current: 150_000, max: 620_500 },
|
||||||
|
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||||
|
power: 1_234,
|
||||||
|
taxRate: state.nationRate ?? 20,
|
||||||
|
strategicCommandLimit: 2,
|
||||||
|
diplomaticLimit: 0,
|
||||||
|
prohibitScout: false,
|
||||||
|
prohibitWar: true,
|
||||||
|
techLevel: 0,
|
||||||
|
techLimited: false,
|
||||||
|
topChiefs: {
|
||||||
|
12: { id: 1, name: '군주', npcState: 0 },
|
||||||
|
11: { id: 2, name: '참모', npcState: 1 },
|
||||||
|
},
|
||||||
|
impossibleStrategicCommands: [{ name: '수몰', remainingTurns: 2, availableYear: 190, availableMonth: 5 }],
|
||||||
},
|
},
|
||||||
settings: {},
|
settings: {},
|
||||||
penalties: {},
|
penalties: {},
|
||||||
@@ -572,18 +590,20 @@ const persistArtifact = async (page: Page, name: string) => {
|
|||||||
executionStatus: describe('.execution-status'),
|
executionStatus: describe('.execution-status'),
|
||||||
tournamentStatus: describe('.tournament-status'),
|
tournamentStatus: describe('.tournament-status'),
|
||||||
voteStatus: describe('.vote-status'),
|
voteStatus: describe('.vote-status'),
|
||||||
|
autoRefresh: describe('[data-bottom-menu="auto-refresh"]'),
|
||||||
|
manualRefresh: describe('[data-bottom-menu="manual-refresh"]'),
|
||||||
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
||||||
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
commandDividers: [
|
||||||
(element) => {
|
...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider'),
|
||||||
const rect = element.getBoundingClientRect();
|
].map((element) => {
|
||||||
const style = getComputedStyle(element);
|
const rect = element.getBoundingClientRect();
|
||||||
return {
|
const style = getComputedStyle(element);
|
||||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
return {
|
||||||
borderTop: style.borderTop,
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
margin: style.margin,
|
borderTop: style.borderTop,
|
||||||
};
|
margin: style.margin,
|
||||||
}
|
};
|
||||||
),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
|
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
|
||||||
@@ -877,6 +897,31 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
|
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
|
||||||
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
||||||
|
|
||||||
|
const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
|
||||||
|
await expect(nationCard.locator('.head')).toHaveCount(17);
|
||||||
|
await expect(nationCard).toContainText('유가 (농상↑ 민심↑쌀수입↓)');
|
||||||
|
await expect(nationCard).toContainText('영주군주참모ⓝ참모');
|
||||||
|
await expect(nationCard).toContainText('총 주민150,000 / 620,500');
|
||||||
|
await expect(nationCard).toContainText('총 병사500 / 7,000');
|
||||||
|
await expect(nationCard).toContainText('지급률100%');
|
||||||
|
await expect(nationCard).toContainText('전략2턴');
|
||||||
|
await expect(nationCard).toContainText('임관허가');
|
||||||
|
await expect(nationCard).toContainText('전쟁금지');
|
||||||
|
expect(await nationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
|
||||||
|
const nationRowHeights = await nationCard
|
||||||
|
.locator('.nation-grid')
|
||||||
|
.evaluate((element) => [...element.children].map((child) => child.getBoundingClientRect().height));
|
||||||
|
expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01);
|
||||||
|
const strategicCell = nationCard.locator('.strategic');
|
||||||
|
const strategicTooltip = strategicCell.getByRole('tooltip');
|
||||||
|
await expect(strategicTooltip).toBeHidden();
|
||||||
|
await strategicCell.hover();
|
||||||
|
await expect(strategicTooltip).toBeVisible();
|
||||||
|
await expect(strategicTooltip).toContainText('수몰: 2턴 뒤(190년 5월부터)');
|
||||||
|
await strategicCell.focus();
|
||||||
|
await expect(strategicCell).toBeFocused();
|
||||||
|
await expect(strategicTooltip).toBeVisible();
|
||||||
|
|
||||||
expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9);
|
expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9);
|
||||||
expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
||||||
expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
||||||
@@ -978,8 +1023,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
await modeButton.focus();
|
await modeButton.focus();
|
||||||
await expect(modeButton).toBeFocused();
|
await expect(modeButton).toBeFocused();
|
||||||
await modeButton.click();
|
await modeButton.click();
|
||||||
const advancedControlGeometry = await page.locator('[data-main-target="commands"] .reserved-command-editor').evaluate(
|
const advancedControlGeometry = await page
|
||||||
(editor) => {
|
.locator('[data-main-target="commands"] .reserved-command-editor')
|
||||||
|
.evaluate((editor) => {
|
||||||
const range = editor.querySelector<HTMLElement>('.range-menu');
|
const range = editor.querySelector<HTMLElement>('.range-menu');
|
||||||
const recent = [...editor.querySelectorAll<HTMLElement>('.control-pad summary')].find((element) =>
|
const recent = [...editor.querySelectorAll<HTMLElement>('.control-pad summary')].find((element) =>
|
||||||
element.textContent?.includes('최근 실행')
|
element.textContent?.includes('최근 실행')
|
||||||
@@ -994,8 +1040,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
advancedBottom: advanced.getBoundingClientRect().bottom,
|
advancedBottom: advanced.getBoundingClientRect().bottom,
|
||||||
queueTop: queue.getBoundingClientRect().top,
|
queueTop: queue.getBoundingClientRect().top,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
);
|
|
||||||
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
||||||
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
||||||
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
||||||
@@ -1171,6 +1216,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
||||||
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
|
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
|
||||||
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
||||||
|
const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
|
||||||
|
expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
|
||||||
|
expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(
|
||||||
|
0
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
await page
|
await page
|
||||||
.locator('[data-main-target="city"] [role="progressbar"]')
|
.locator('[data-main-target="city"] [role="progressbar"]')
|
||||||
@@ -1375,12 +1425,7 @@ test('real mobile devices initially fit the complete 500px game canvas', async (
|
|||||||
|
|
||||||
const routeGeometry: Record<string, unknown> = {};
|
const routeGeometry: Record<string, unknown> = {};
|
||||||
if (deviceWidth === 390) {
|
if (deviceWidth === 390) {
|
||||||
for (const target of [
|
for (const target of ['chief-center', 'battle-center', 'inherit', 'nation-betting']) {
|
||||||
'chief-center',
|
|
||||||
'battle-center',
|
|
||||||
'inherit',
|
|
||||||
'nation-betting',
|
|
||||||
]) {
|
|
||||||
await mobilePage.goto(target);
|
await mobilePage.goto(target);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
|
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
|
||||||
@@ -1472,6 +1517,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
|||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
};
|
};
|
||||||
|
await installRealtimeHarness(page);
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
await waitForMain(page);
|
await waitForMain(page);
|
||||||
@@ -1494,9 +1540,65 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
|||||||
await expect(page.locator(selector)).toBeVisible();
|
await expect(page.locator(selector)).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoRefresh = page.getByRole('button', { name: '자동 갱신 ON' });
|
||||||
|
const manualRefresh = page.getByRole('button', { name: '직접 갱신' });
|
||||||
|
await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)');
|
||||||
|
await expect(manualRefresh).toHaveAttribute('aria-busy', 'false');
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()))
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => {
|
||||||
|
const auto = controls.querySelector<HTMLElement>('[data-bottom-menu="auto-refresh"]');
|
||||||
|
const manual = controls.querySelector<HTMLElement>('[data-bottom-menu="manual-refresh"]');
|
||||||
|
if (!auto || !manual) throw new Error('mobile refresh controls are incomplete');
|
||||||
|
const controlsRect = controls.getBoundingClientRect();
|
||||||
|
const autoRect = auto.getBoundingClientRect();
|
||||||
|
const manualRect = manual.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
controls: { left: controlsRect.left, right: controlsRect.right, width: controlsRect.width },
|
||||||
|
auto: { left: autoRect.left, right: autoRect.right, width: autoRect.width },
|
||||||
|
manual: { left: manualRect.left, right: manualRect.right, width: manualRect.width },
|
||||||
|
overflow: controls.scrollWidth - controls.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(refreshGeometry.controls.width).toBe(125);
|
||||||
|
expect(refreshGeometry.auto.width).toBe(85);
|
||||||
|
expect(refreshGeometry.manual.width).toBe(40);
|
||||||
|
expect(refreshGeometry.auto.left).toBe(refreshGeometry.controls.left);
|
||||||
|
expect(refreshGeometry.auto.right).toBe(refreshGeometry.manual.left);
|
||||||
|
expect(refreshGeometry.manual.right).toBe(refreshGeometry.controls.right);
|
||||||
|
expect(refreshGeometry.overflow).toBeLessThanOrEqual(0);
|
||||||
|
|
||||||
|
await autoRefresh.focus();
|
||||||
|
await expect(autoRefresh).toBeFocused();
|
||||||
|
await autoRefresh.hover();
|
||||||
|
await expect(autoRefresh).toHaveCSS('filter', 'brightness(1.14)');
|
||||||
|
await autoRefresh.click();
|
||||||
|
const disabledAutoRefresh = page.getByRole('button', { name: '자동 갱신 OFF' });
|
||||||
|
await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)');
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()))
|
||||||
|
.toBe(false);
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`);
|
||||||
|
|
||||||
|
state.generalName = '직접갱신된장수';
|
||||||
const callsBeforeRefresh = state.generalMeCalls;
|
const callsBeforeRefresh = state.generalMeCalls;
|
||||||
await page.getByRole('button', { name: '갱 신' }).click();
|
await manualRefresh.click();
|
||||||
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
|
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||||
|
await expect(page.locator('.general-title')).toContainText('직접갱신된장수');
|
||||||
|
|
||||||
|
const callsBeforeEnable = state.generalMeCalls;
|
||||||
|
await page.getByRole('button', { name: '자동 갱신 OFF' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()))
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`);
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
localStorage.setItem('sammo-session-token', 'session_navigation');
|
localStorage.setItem('sammo-session-token', 'session_navigation');
|
||||||
@@ -1734,10 +1836,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
{ op: 'replace', path: '/general/0/values/1/possible', value: false },
|
{ op: 'replace', path: '/general/0/values/1/possible', value: false },
|
||||||
{ op: 'replace', path: '/general/0/values/1/status', value: 'blocked' },
|
{ op: 'replace', path: '/general/0/values/1/status', value: 'blocked' },
|
||||||
];
|
];
|
||||||
await emitReadModelInvalidation(
|
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true }));
|
||||||
page,
|
|
||||||
readModelInvalidation({ context: true, commands: true, boardAccess: true })
|
|
||||||
);
|
|
||||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
|
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
|
||||||
|
|
||||||
const callsBeforeCityState = state.generalMeCalls;
|
const callsBeforeCityState = state.generalMeCalls;
|
||||||
@@ -1764,10 +1863,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
state.contextRevision = 'O'.repeat(22);
|
state.contextRevision = 'O'.repeat(22);
|
||||||
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
|
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
|
||||||
state.commandTableOperations = [];
|
state.commandTableOperations = [];
|
||||||
await emitReadModelInvalidation(
|
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true }));
|
||||||
page,
|
|
||||||
readModelInvalidation({ context: true, commands: true, boardAccess: true })
|
|
||||||
);
|
|
||||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2);
|
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2);
|
||||||
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
|
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
|
||||||
await expect(page.locator('.general-title')).toContainText('snapshot복구장수');
|
await expect(page.locator('.general-title')).toContainText('snapshot복구장수');
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ const props = defineProps<{
|
|||||||
tournamentStage: number;
|
tournamentStage: number;
|
||||||
nationColor: string;
|
nationColor: string;
|
||||||
npcMode: number;
|
npcMode: number;
|
||||||
|
realtimeEnabled: boolean;
|
||||||
|
refreshing: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: [];
|
refresh: [];
|
||||||
|
toggleRealtime: [];
|
||||||
lobby: [];
|
lobby: [];
|
||||||
quick: [item: QuickNavigationItem];
|
quick: [item: QuickNavigationItem];
|
||||||
}>();
|
}>();
|
||||||
@@ -189,14 +192,31 @@ const onQuick = (item: QuickNavigationItem) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="bottom-refresh-controls">
|
||||||
class="bottom-trigger refresh-trigger"
|
<button
|
||||||
type="button"
|
class="bottom-trigger auto-refresh-trigger"
|
||||||
data-bottom-menu="refresh"
|
:class="{ active: realtimeEnabled }"
|
||||||
@click="emit('refresh')"
|
type="button"
|
||||||
>
|
data-bottom-menu="auto-refresh"
|
||||||
갱신
|
:aria-pressed="realtimeEnabled"
|
||||||
</button>
|
@click="emit('toggleRealtime')"
|
||||||
|
>
|
||||||
|
<span>자동 갱신</span>
|
||||||
|
<strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bottom-trigger manual-refresh-trigger"
|
||||||
|
type="button"
|
||||||
|
data-bottom-menu="manual-refresh"
|
||||||
|
aria-label="직접 갱신"
|
||||||
|
title="직접 갱신"
|
||||||
|
:disabled="refreshing"
|
||||||
|
:aria-busy="refreshing"
|
||||||
|
@click="emit('refresh')"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↻</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -221,6 +241,17 @@ const onQuick = (item: QuickNavigationItem) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-refresh-controls {
|
||||||
|
display: grid;
|
||||||
|
width: 125px;
|
||||||
|
height: 45px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-refresh-controls > .bottom-trigger {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-trigger {
|
.bottom-trigger {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 125px;
|
width: 125px;
|
||||||
@@ -245,6 +276,43 @@ const onQuick = (item: QuickNavigationItem) => {
|
|||||||
background: #212529;
|
background: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-refresh-trigger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 1px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-trigger strong {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-trigger.active {
|
||||||
|
background-color: #164f2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-trigger.active strong {
|
||||||
|
color: #9ef0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-refresh-trigger {
|
||||||
|
padding: 0;
|
||||||
|
background: #212529;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-refresh-trigger:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
filter: grayscale(0.6);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-trigger:hover,
|
.bottom-trigger:hover,
|
||||||
.bottom-trigger:focus-visible,
|
.bottom-trigger:focus-visible,
|
||||||
.bottom-trigger[aria-expanded='true'] {
|
.bottom-trigger[aria-expanded='true'] {
|
||||||
|
|||||||
@@ -1,86 +1,296 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||||
|
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||||
|
import { getNpcColor } from '../../utils/npcColor';
|
||||||
|
|
||||||
|
interface NationChief {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
npcState: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface NationInfo {
|
interface NationInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
level: number;
|
level: number;
|
||||||
levelName: string;
|
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
tech: number;
|
tech: number;
|
||||||
typeCode: string;
|
|
||||||
typeName: string;
|
typeName: string;
|
||||||
capitalCityId: number | null;
|
typePros: string;
|
||||||
capitalCityName: string | null;
|
typeCons: string;
|
||||||
|
population: { cityCount: number; current: number; max: number };
|
||||||
|
crew: { generalCount: number; current: number; max: number };
|
||||||
|
power: number;
|
||||||
|
bill: number;
|
||||||
|
taxRate: number;
|
||||||
|
strategicCommandLimit: number;
|
||||||
|
diplomaticLimit: number;
|
||||||
|
prohibitScout: boolean;
|
||||||
|
prohibitWar: boolean;
|
||||||
|
techLevel: number;
|
||||||
|
techLimited: boolean;
|
||||||
|
topChiefs: Record<number, NationChief | undefined>;
|
||||||
|
impossibleStrategicCommands: Array<{
|
||||||
|
name: string;
|
||||||
|
remainingTurns: number;
|
||||||
|
availableYear: number;
|
||||||
|
availableMonth: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
nation: NationInfo | null;
|
nation: NationInfo | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const number = (value: number): string => value.toLocaleString('ko-KR');
|
||||||
|
const displayChiefName = (chief: NationChief | undefined): string => {
|
||||||
|
if (!chief) return '-';
|
||||||
|
return chief.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(chief.name) ? `ⓝ${chief.name}` : chief.name;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="nation-card">
|
<div class="nation-card" data-nation-basic-card>
|
||||||
<div v-if="props.loading">
|
<div v-if="props.loading" class="loading"><SkeletonLines :lines="6" /></div>
|
||||||
<SkeletonLines :lines="4" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
|
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
|
||||||
<div v-else class="nation-body">
|
<div v-else class="nation-grid">
|
||||||
<div
|
<div
|
||||||
class="title"
|
class="title"
|
||||||
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
|
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
|
||||||
>
|
>
|
||||||
{{ props.nation.name }}<template v-if="props.nation.id > 0"> ({{ props.nation.levelName }})</template>
|
{{ props.nation.name }}
|
||||||
</div>
|
|
||||||
<div class="grid">
|
|
||||||
<span>국고</span
|
|
||||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.gold.toLocaleString() }}</strong>
|
|
||||||
<span>국량</span
|
|
||||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong>
|
|
||||||
<span>기술</span
|
|
||||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }}</strong>
|
|
||||||
<span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeName }}</strong>
|
|
||||||
<span>수도</span
|
|
||||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityName ?? '-') }}</strong>
|
|
||||||
<span>국가 등급</span
|
|
||||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.levelName }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span class="head">성향</span>
|
||||||
|
<strong class="body type-body">
|
||||||
|
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||||
|
<span class="cons">{{ props.nation.typeCons }}</span
|
||||||
|
>)
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
|
||||||
|
<strong class="body" :style="{ color: getNpcColor(props.nation.topChiefs[12]?.npcState ?? 1) }">
|
||||||
|
{{ displayChiefName(props.nation.topChiefs[12]) }}
|
||||||
|
</strong>
|
||||||
|
<span class="head">{{ formatOfficerLevelText(11, props.nation.level) }}</span>
|
||||||
|
<strong class="body" :style="{ color: getNpcColor(props.nation.topChiefs[11]?.npcState ?? 1) }">
|
||||||
|
{{ displayChiefName(props.nation.topChiefs[11]) }}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span class="head">총 주민</span>
|
||||||
|
<strong class="body">{{
|
||||||
|
props.nation.id === 0
|
||||||
|
? '해당 없음'
|
||||||
|
: `${number(props.nation.population.current)} / ${number(props.nation.population.max)}`
|
||||||
|
}}</strong>
|
||||||
|
<span class="head">총 병사</span>
|
||||||
|
<strong class="body">{{
|
||||||
|
props.nation.id === 0
|
||||||
|
? '해당 없음'
|
||||||
|
: `${number(props.nation.crew.current)} / ${number(props.nation.crew.max)}`
|
||||||
|
}}</strong>
|
||||||
|
|
||||||
|
<span class="head">국고</span>
|
||||||
|
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : number(props.nation.gold) }}</strong>
|
||||||
|
<span class="head">병량</span>
|
||||||
|
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : number(props.nation.rice) }}</strong>
|
||||||
|
|
||||||
|
<span class="head">지급률</span>
|
||||||
|
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : `${props.nation.bill}%` }}</strong>
|
||||||
|
<span class="head">세율</span>
|
||||||
|
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : `${props.nation.taxRate}%` }}</strong>
|
||||||
|
|
||||||
|
<span class="head">속령</span>
|
||||||
|
<strong class="body">{{
|
||||||
|
props.nation.id === 0 ? '해당 없음' : number(props.nation.population.cityCount)
|
||||||
|
}}</strong>
|
||||||
|
<span class="head">장수</span>
|
||||||
|
<strong class="body">{{
|
||||||
|
props.nation.id === 0 ? '해당 없음' : number(props.nation.crew.generalCount)
|
||||||
|
}}</strong>
|
||||||
|
|
||||||
|
<span class="head">국력</span>
|
||||||
|
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : number(props.nation.power) }}</strong>
|
||||||
|
<span class="head">기술력</span>
|
||||||
|
<strong class="body">
|
||||||
|
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.nation.techLevel }}등급 /
|
||||||
|
<span :class="props.nation.techLimited ? 'tech-limited' : 'available'">{{
|
||||||
|
number(Math.floor(props.nation.tech))
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span class="head">전략</span>
|
||||||
|
<strong
|
||||||
|
class="body strategic"
|
||||||
|
:class="{ 'has-tooltip': props.nation.impossibleStrategicCommands.length > 0 }"
|
||||||
|
:tabindex="props.nation.impossibleStrategicCommands.length > 0 ? 0 : undefined"
|
||||||
|
>
|
||||||
|
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||||
|
<span v-else-if="props.nation.strategicCommandLimit" class="blocked"
|
||||||
|
>{{ number(props.nation.strategicCommandLimit) }}턴</span
|
||||||
|
>
|
||||||
|
<span v-else :class="props.nation.impossibleStrategicCommands.length > 0 ? 'warning' : 'available'"
|
||||||
|
>가능</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="props.nation.impossibleStrategicCommands.length > 0"
|
||||||
|
class="cooldown-tooltip"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<span v-for="command in props.nation.impossibleStrategicCommands" :key="command.name">
|
||||||
|
{{ command.name }}: {{ number(command.remainingTurns) }}턴 뒤({{ command.availableYear }}년
|
||||||
|
{{ command.availableMonth }}월부터)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</strong>
|
||||||
|
<span class="head">외교</span>
|
||||||
|
<strong class="body">
|
||||||
|
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||||
|
<span v-else-if="props.nation.diplomaticLimit" class="blocked"
|
||||||
|
>{{ number(props.nation.diplomaticLimit) }}턴</span
|
||||||
|
>
|
||||||
|
<span v-else class="available">가능</span>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span class="head">임관</span>
|
||||||
|
<strong class="body">
|
||||||
|
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||||
|
<span v-else :class="props.nation.prohibitScout ? 'blocked' : 'available'">{{
|
||||||
|
props.nation.prohibitScout ? '금지' : '허가'
|
||||||
|
}}</span>
|
||||||
|
</strong>
|
||||||
|
<span class="head">전쟁</span>
|
||||||
|
<strong class="body">
|
||||||
|
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||||
|
<span v-else :class="props.nation.prohibitWar ? 'blocked' : 'available'">{{
|
||||||
|
props.nation.prohibitWar ? '금지' : '허가'
|
||||||
|
}}</span>
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.title {
|
.nation-card {
|
||||||
min-height: 24px;
|
box-sizing: border-box;
|
||||||
padding: 2px 6px;
|
width: 100%;
|
||||||
text-align: center;
|
min-width: 0;
|
||||||
font-weight: 600;
|
height: 193px;
|
||||||
}
|
color: #fff;
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.grid > * {
|
|
||||||
min-height: 23px;
|
.nation-grid {
|
||||||
|
display: grid;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-right: 1px solid #666;
|
width: 100%;
|
||||||
border-bottom: 1px solid #666;
|
height: 193px;
|
||||||
padding: 2px 5px;
|
grid-template-columns: 14% 36% 14% 36%;
|
||||||
|
grid-template-rows: repeat(10, calc(192px / 10));
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
background-color: #172a52;
|
||||||
|
background-image: var(--sammo-texture-blue);
|
||||||
}
|
}
|
||||||
.grid > span {
|
|
||||||
background: rgb(20 75 42 / 70%);
|
.nation-grid > * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
border-left: 1px solid gray;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: calc(193px / 10);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.grid > strong {
|
|
||||||
text-align: right;
|
.title {
|
||||||
|
grid-column: 1 / 5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
background-color: rgb(20 75 42 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
position: relative;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-body {
|
||||||
|
grid-column: 2 / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pros {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cons,
|
||||||
|
.tech-limited {
|
||||||
|
color: magenta;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available {
|
||||||
|
color: limegreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategic.has-tooltip {
|
||||||
|
overflow: visible;
|
||||||
|
text-decoration: underline dashed red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooldown-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
bottom: calc(100% + 3px);
|
||||||
|
left: 50%;
|
||||||
|
display: none;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 280px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 1px solid #888;
|
||||||
|
padding: 4px 7px;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooldown-tooltip > span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tooltip:hover .cooldown-tooltip,
|
||||||
|
.has-tooltip:focus .cooldown-tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 193px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -455,7 +455,10 @@ watch(
|
|||||||
:tournament-stage="tournamentStage"
|
:tournament-stage="tournamentStage"
|
||||||
:nation-color="nationColor"
|
:nation-color="nationColor"
|
||||||
:npc-mode="npcMode"
|
:npc-mode="npcMode"
|
||||||
|
:realtime-enabled="realtimeEnabled"
|
||||||
|
:refreshing="refreshing"
|
||||||
@refresh="loadMainData"
|
@refresh="loadMainData"
|
||||||
|
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||||
@lobby="moveLobby"
|
@lobby="moveLobby"
|
||||||
@quick="moveQuick"
|
@quick="moveQuick"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-1
@@ -27,7 +27,14 @@ SET
|
|||||||
ALTER TABLE "gateway_profile"
|
ALTER TABLE "gateway_profile"
|
||||||
ALTER COLUMN "instance_key" SET NOT NULL;
|
ALTER COLUMN "instance_key" SET NOT NULL;
|
||||||
|
|
||||||
DROP INDEX "gateway_profile_profile_scenario_key";
|
-- Older combined-schema installs created this uniqueness rule as a table
|
||||||
|
-- constraint, while the standalone Gateway baseline created a unique index.
|
||||||
|
-- Dropping the constraint first also removes its backing index; the second
|
||||||
|
-- statement handles the standalone-index shape and is then a no-op otherwise.
|
||||||
|
ALTER TABLE "gateway_profile"
|
||||||
|
DROP CONSTRAINT IF EXISTS "gateway_profile_profile_scenario_key";
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "gateway_profile_profile_scenario_key";
|
||||||
|
|
||||||
ALTER TABLE "gateway_profile"
|
ALTER TABLE "gateway_profile"
|
||||||
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
|
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user