인사부 후보 정렬과 추방 확인 개선

This commit is contained in:
2026-08-31 18:42:33 +00:00
parent bf13dabc01
commit 50a443d94b
2 changed files with 116 additions and 34 deletions
+65 -11
View File
@@ -15,6 +15,7 @@ type FixtureState = {
appointedGeneralId?: number;
appointedCityId?: number;
appointedOfficerLevel?: number;
kickedGeneralId?: number;
permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] };
noticeMutationInput?: string;
scoutMutationInput?: string;
@@ -101,7 +102,7 @@ const personnelFixture = (state: FixtureState) => {
general(3, '하후돈', 4),
general(4, '곽가', 3),
general(5, '정욱', 2),
general(6, '장료', 1),
general(6, '장료', 1, { npcState: 2 }),
general(7, '허저', 1, { permission: 'ambassador' }),
general(8, '가후', 1, { permission: 'auditor' }),
general(9, '전위', 1),
@@ -256,7 +257,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
};
return response({ ok: true });
}
if (operation === 'nation.kick') return response({ ok: true });
if (operation === 'nation.kick') {
state.kickedGeneralId = Number(jsonInput.destGeneralId ?? 0);
return response({ ok: true });
}
if (operation === 'nation.setRate') {
if (state.failNextRate) {
state.failNextRate = false;
@@ -425,6 +429,16 @@ test('personnel selects an informed general and reports the JosaUtil-composed re
const picker = page.getByTestId('personnel-selection-dialog');
await expect(picker).toBeVisible();
await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible();
await expect(picker.locator('.personnel-picker-card:not(.vacancy-card)')).toHaveText([
/가후/,
/곽가/,
/순욱/,
/전위/,
/정욱/,
/하후돈/,
/허저/,
/장료/,
]);
await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료');
const candidate = picker.getByRole('button', { name: /장료/ });
await expect(candidate).toContainText('허창 · 일반 장수');
@@ -476,14 +490,7 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
expect(rowGeometry.officerWidth).toBeLessThan(141);
expect(rowGeometry.gradientCount).toBe(0);
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
'장수 선택',
'하후돈 (70/70/70)',
'곽가 (70/70/70)',
'정욱 (70/70/70)',
'장료 (70/70/70)',
]);
await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toBeVisible();
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog');
@@ -561,6 +568,53 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
await screenshot(page, 'core-personnel-mobile-rows.png');
});
test('personnel chooses a kick target in the informed picker and double-confirms a user general', async ({ page }) => {
const state: FixtureState = { role: 'head', rate: 20 };
await installFixture(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await gotoOffice(page, 'nation/personnel');
const kickLabel = page.locator('.kick-label');
await expect(kickLabel).toHaveText('대상 장수');
expect(await kickLabel.evaluate((element) => getComputedStyle(element).whiteSpace)).toBe('nowrap');
await page.getByRole('button', { name: '추방 대상 선택하기' }).click();
const picker = page.getByTestId('personnel-selection-dialog');
await expect(picker.getByRole('heading', { name: '추방 대상 선택' })).toBeVisible();
await expect(picker.locator('.vacancy-card')).toHaveCount(0);
await expect(picker.locator('.personnel-picker-card')).toHaveText([
/가후/,
/곽가/,
/전위/,
/정욱/,
/하후돈/,
/장료/,
]);
await picker.getByRole('button', { name: /정욱/ }).click();
await expect(picker).toBeHidden();
await expect(page.getByRole('button', { name: '정욱', exact: true })).toBeVisible();
await page.getByRole('button', { name: '추방', exact: true }).click();
const confirmation = page.getByTestId('game-notice-dialog');
await expect(confirmation).toContainText('정욱을 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '확인', exact: true }).click();
await expect(confirmation).toContainText('정욱은 유저장입니다. 그래도 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '취소', exact: true }).click();
await expect(confirmation).toBeHidden();
expect(state.kickedGeneralId).toBeUndefined();
await page.getByRole('button', { name: '추방', exact: true }).click();
await expect(confirmation).toContainText('정욱을 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '확인', exact: true }).click();
await expect(confirmation).toContainText('정욱은 유저장입니다. 그래도 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '확인', exact: true }).click();
await expect(confirmation).toBeHidden();
await expect(page.getByTestId('game-toast')).toContainText('정욱을 추방했습니다.');
expect(state.kickedGeneralId).toBe(5);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
await screenshot(page, 'core-personnel-mobile-kick-picker.png');
});
test('@ios-webkit iPhone touch appoints a city officer after the picker closes', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required');
@@ -589,7 +643,7 @@ test('personnel hides every mutation control for an ordinary member and exposes
await gotoOffice(page, 'nation/personnel');
await expect(page.getByRole('button', { name: /변경하기/ })).toHaveCount(0);
await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '추방', exact: true })).toHaveCount(0);
const auditorCell = page.locator('.city-officer-cell').filter({ hasText: '곽가' });
await expect(auditorCell).toContainText('10년 · 허창');
@@ -8,6 +8,7 @@ import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { trpc } from '../utils/trpc';
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
import { legacyNationTextColor } from '../utils/legacyNationColor';
@@ -27,7 +28,9 @@ type SelectionDialogItem = {
details: Array<{ label: string; value: string }>;
};
type SelectionContext =
{ kind: 'chief-general'; level: number } | { kind: 'city-general'; level: OfficerLevel; cityId: number };
| { kind: 'chief-general'; level: number }
| { kind: 'city-general'; level: OfficerLevel; cityId: number }
| { kind: 'kick-general' };
const officerLabels: Record<OfficerLevel, string> = { 4: '태수', 3: '군사', 2: '종사' };
const cityOfficerLevels: OfficerLevel[] = [4, 3, 2];
@@ -88,20 +91,26 @@ const currentOfficeText = (general: GeneralEntry): string => {
const chiefCandidates = (level: number): GeneralEntry[] => {
const minimum = data.value?.chiefStatMin ?? 0;
const candidates = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12);
if (level === 11) return candidates;
if (level % 2 === 0) return candidates.filter((general) => general.stats.strength >= minimum);
return candidates.filter((general) => general.stats.intelligence >= minimum);
if (level === 11) return sortGeneralsByTypeThenName(candidates);
if (level % 2 === 0)
return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.strength >= minimum));
return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.intelligence >= minimum));
};
const cityCandidates = (level: OfficerLevel): GeneralEntry[] => {
const minimum = data.value?.chiefStatMin ?? 0;
const candidates = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12);
if (level === 4) return candidates.filter((general) => general.stats.strength >= minimum);
if (level === 3) return candidates.filter((general) => general.stats.intelligence >= minimum);
return candidates;
if (level === 4)
return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.strength >= minimum));
if (level === 3)
return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.intelligence >= minimum));
return sortGeneralsByTypeThenName(candidates);
};
const kickCandidates = computed(() =>
(data.value?.generals ?? []).filter(
(general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador'
sortGeneralsByTypeThenName(
(data.value?.generals ?? []).filter(
(general) =>
general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador'
)
)
);
const awardText = (entries: PersonnelResponse['awards']['tigers']): string =>
@@ -208,6 +217,7 @@ const selectionTitle = computed(() => {
if (context.kind === 'chief-general') {
return `${formatOfficerLevelText(context.level, nationLevel.value)} 임명 대상 선택`;
}
if (context.kind === 'kick-general') return '추방 대상 선택';
const city = data.value?.cityAssignments.find((entry) => entry.id === context.cityId);
return `${city?.name ?? ''} ${officerLabels[context.level]} 변경`;
});
@@ -219,6 +229,9 @@ const selectionDescription = computed(() => {
const stat = context.level % 2 === 0 ? '무력' : '지력';
return `${stat} ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 주요 능력치를 함께 확인하세요.`;
}
if (context.kind === 'kick-general') {
return '현재 관직과 소재지, 장수 유형을 확인한 뒤 추방할 장수를 선택하세요.';
}
if (context.level === 4) {
return `무력 ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 소재지를 함께 확인하세요.`;
}
@@ -234,6 +247,9 @@ const selectionItems = computed<SelectionDialogItem[]>(() => {
const currentGeneralId = chiefAssignments.value[context.level]?.id ?? 0;
return chiefCandidates(context.level).map((general) => generalSelectionItem(general, currentGeneralId));
}
if (context.kind === 'kick-general') {
return kickCandidates.value.map((general) => generalSelectionItem(general, kickTargetId.value));
}
const city = data.value?.cityAssignments.find((entry) => entry.id === context.cityId);
const currentGeneralId = city?.officers[context.level]?.id ?? 0;
return cityCandidates(context.level).map((general) => generalSelectionItem(general, currentGeneralId));
@@ -242,6 +258,7 @@ const selectionId = computed(() => {
const context = selectionContext.value;
if (!context) return 0;
if (context.kind === 'chief-general') return chiefAssignments.value[context.level]?.id ?? 0;
if (context.kind === 'kick-general') return kickTargetId.value;
return data.value?.cityAssignments.find((entry) => entry.id === context.cityId)?.officers[context.level]?.id ?? 0;
});
const applySelection = async (id: number): Promise<void> => {
@@ -250,7 +267,8 @@ const applySelection = async (id: number): Promise<void> => {
selectionContext.value = null;
await nextTick();
if (context.kind === 'chief-general') await appointChief(context.level, id);
else await appointCityOfficer(context.level, context.cityId, id);
else if (context.kind === 'city-general') await appointCityOfficer(context.level, context.cityId, id);
else kickTargetId.value = id;
};
const reportPermissionLimit = () => {
@@ -269,6 +287,12 @@ const changePermissions = async (isAmbassador: boolean) => {
const kickGeneral = async () => {
const target = generalMap.value.get(kickTargetId.value);
if (!target || !(await showConfirm(`${JosaUtil.put(target.name, '을')} 추방하시겠습니까?`))) return;
if (
target.npcState === 0 &&
!(await showConfirm(`${JosaUtil.put(target.name, '은')} 유저장입니다. 그래도 추방하시겠습니까?`))
) {
return;
}
await runMutation(
() => trpc.nation.kick.mutate({ destGeneralId: target.id }),
`${JosaUtil.put(target.name, '을')} 추방했습니다.`
@@ -520,18 +544,13 @@ onMounted(() => void loadPersonnel());
<td class="green-cell kick-label">대상 장수</td>
<td>
<template v-if="canKick">
<select v-model.number="kickTargetId" aria-label="추방 대상 장수">
<option :value="0">장수 선택</option>
<option
v-for="candidate in kickCandidates"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }} ({{ candidate.stats.leadership }}/{{
candidate.stats.strength
}}/{{ candidate.stats.intelligence }})
</option>
</select>
<button
class="legacy-button kick-select-button"
type="button"
@click="selectionContext = { kind: 'kick-general' }"
>
{{ generalMap.get(kickTargetId)?.name ?? '추방 대상 선택하기' }}
</button>
<button
class="legacy-button legacy-button--danger"
type="button"
@@ -580,7 +599,7 @@ onMounted(() => void loadPersonnel());
:items="selectionItems"
:selected-id="selectionId"
search-placeholder="장수명·도시·관직·특성 검색"
vacancy-label="공석으로 두기"
:vacancy-label="selectionContext?.kind === 'kick-general' ? null : '공석으로 두기'"
@cancel="selectionContext = null"
@select="applySelection"
/>
@@ -862,6 +881,15 @@ select {
.kick-label {
width: 498px;
text-align: right;
white-space: nowrap;
}
.kick-select-button {
min-width: 150px;
max-width: calc(100% - 52px);
overflow: hidden;
vertical-align: middle;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-table {
margin-top: 18px;