fix: Ref 장수 기본 카드 표시를 보완
This commit is contained in:
@@ -201,9 +201,13 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
name: state.generalName ?? '메뉴검증장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
troopId: 7,
|
||||
npcState: 0,
|
||||
officerLevel: state.officerLevel,
|
||||
officerLevelText: state.officerLevel === 0 ? '재야' : '군주',
|
||||
officerCityName: state.officerLevel >= 2 && state.officerLevel <= 4 ? '업' : null,
|
||||
generalType: '용장',
|
||||
leadershipBonus: state.officerLevel === 12 ? state.nationLevel * 2 : 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
@@ -215,6 +219,13 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
injury: 0,
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
age: 25,
|
||||
retirementYear: 70,
|
||||
defenceTrain: 80,
|
||||
killTurn: 5,
|
||||
remainingMinutes: 7,
|
||||
troop: { name: '백마대', status: 'present', leaderCityName: '업' },
|
||||
refreshScore: { current: 3, total: 120, text: '보통' },
|
||||
progression: {
|
||||
experienceLevel: 1,
|
||||
dedicationLevel: 2,
|
||||
@@ -223,6 +234,10 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
|
||||
},
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
itemNames: { horse: '적토마', weapon: '청룡언월도', book: '육도', item: '옥벽' },
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
traits: { personal: '대담', specialDomestic: '상재', specialWar: '무쌍' },
|
||||
turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z',
|
||||
},
|
||||
city: {
|
||||
@@ -739,7 +754,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('main general card renders the next turn in the Seoul server timezone', async ({ page }) => {
|
||||
test('main general card renders Ref title and omitted rows with second precision', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 0,
|
||||
permission: 0,
|
||||
@@ -759,8 +774,15 @@ test('main general card renders the next turn in the Seoul server timezone', asy
|
||||
|
||||
const title = page.locator('[data-main-target="general"] .general-title').first();
|
||||
await expect(title).toContainText('Administrator');
|
||||
await expect(title).toContainText('다음 턴 09:07');
|
||||
await expect(title).toContainText('용장');
|
||||
await expect(title).toContainText('09:07:06');
|
||||
await expect(title).not.toContainText('00:07');
|
||||
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
|
||||
await expect(generalCard).toContainText('수비 함(훈사80)');
|
||||
await expect(generalCard).toContainText('5 턴');
|
||||
await expect(generalCard).toContainText('7분 남음');
|
||||
await expect(generalCard).toContainText('백마대');
|
||||
await expect(generalCard).toContainText('보통 120점(3)');
|
||||
|
||||
const desktopGeometry = await title.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -768,6 +790,8 @@ test('main general card renders the next turn in the Seoul server timezone', asy
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
overflow: style.overflow,
|
||||
@@ -775,6 +799,8 @@ test('main general card renders the next turn in the Seoul server timezone', asy
|
||||
});
|
||||
expect(desktopGeometry.width).toBeGreaterThan(0);
|
||||
expect(desktopGeometry.height).toBeGreaterThan(0);
|
||||
expect(desktopGeometry.scrollWidth - desktopGeometry.clientWidth).toBeLessThanOrEqual(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
if (artifactRoot) {
|
||||
const target = resolve(artifactRoot);
|
||||
await mkdir(target, { recursive: true });
|
||||
@@ -789,8 +815,9 @@ test('main general card renders the next turn in the Seoul server timezone', asy
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
||||
await expect(mobileTitle).toContainText('다음 턴 09:07');
|
||||
await expect(mobileTitle).toContainText('09:07:06');
|
||||
expect(await mobileTitle.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
|
||||
@@ -1546,7 +1573,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
||||
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()))
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => {
|
||||
@@ -1580,7 +1609,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
||||
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()))
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(false);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`);
|
||||
|
||||
@@ -1595,7 +1626,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
||||
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()))
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
||||
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
|
||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||
@@ -29,6 +29,18 @@ interface ItemDisplayNames {
|
||||
item?: string | null;
|
||||
}
|
||||
|
||||
interface GeneralTroopDisplay {
|
||||
name: string;
|
||||
status: 'inactive' | 'present' | 'away';
|
||||
leaderCityName?: string | null;
|
||||
}
|
||||
|
||||
interface GeneralRefreshScore {
|
||||
current: number;
|
||||
total: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface GeneralInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -37,6 +49,9 @@ interface GeneralInfo {
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
officerLevelText: string;
|
||||
officerCityName?: string | null;
|
||||
generalType?: string;
|
||||
leadershipBonus?: number;
|
||||
stats: GeneralStats;
|
||||
gold: number;
|
||||
rice: number;
|
||||
@@ -47,8 +62,14 @@ interface GeneralInfo {
|
||||
experience: number;
|
||||
dedication: number;
|
||||
age?: number;
|
||||
retirementYear?: number;
|
||||
turnTime?: string | null;
|
||||
defenceTrain?: number;
|
||||
killTurn?: number;
|
||||
remainingMinutes?: number | null;
|
||||
troopId?: number;
|
||||
troop?: GeneralTroopDisplay | null;
|
||||
refreshScore?: GeneralRefreshScore;
|
||||
crewTypeId?: number;
|
||||
crewTypeName?: string;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
@@ -87,14 +108,22 @@ const statRows = computed(() => {
|
||||
{
|
||||
key: 'leadership',
|
||||
label: '통솔',
|
||||
value: general.stats.leadership,
|
||||
value: Math.round((general.stats.leadership * (100 - general.injury)) / 100),
|
||||
bonus: general.leadershipBonus ?? 0,
|
||||
accumulated: accumulated?.leadership ?? 0,
|
||||
},
|
||||
{ key: 'strength', label: '무력', value: general.stats.strength, accumulated: accumulated?.strength ?? 0 },
|
||||
{
|
||||
key: 'strength',
|
||||
label: '무력',
|
||||
value: Math.round((general.stats.strength * (100 - general.injury)) / 100),
|
||||
bonus: 0,
|
||||
accumulated: accumulated?.strength ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'intelligence',
|
||||
label: '지력',
|
||||
value: general.stats.intelligence,
|
||||
value: Math.round((general.stats.intelligence * (100 - general.injury)) / 100),
|
||||
bonus: 0,
|
||||
accumulated: accumulated?.intelligence ?? 0,
|
||||
},
|
||||
].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) }));
|
||||
@@ -119,9 +148,9 @@ const crewTypeIconBackground = computed(() => {
|
||||
|
||||
const injuryInfo = computed(() => {
|
||||
const injury = props.general?.injury ?? 0;
|
||||
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
|
||||
if (injury > 60) return { text: '위독', color: '#ff0000' };
|
||||
if (injury > 40) return { text: '심각', color: '#ff00ff' };
|
||||
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
|
||||
if (injury > 20) return { text: '중상', color: '#ffa500' };
|
||||
if (injury > 0) return { text: '경상', color: '#ffff00' };
|
||||
return { text: '건강', color: '#ffffff' };
|
||||
});
|
||||
@@ -143,20 +172,40 @@ const titleStyle = computed(() => {
|
||||
});
|
||||
|
||||
const ageColor = computed(() => {
|
||||
const age = props.general?.age;
|
||||
if (age === undefined) return '#ffffff';
|
||||
if (age < 53) return '#32cd32';
|
||||
if (age < 70) return '#ffff00';
|
||||
return '#ff4d4f';
|
||||
const general = props.general;
|
||||
const age = general?.age;
|
||||
if (!general || age === undefined) return '#ffffff';
|
||||
const retirementYear = general.retirementYear ?? 70;
|
||||
if (age < retirementYear * 0.75) return '#32cd32';
|
||||
if (age < retirementYear) return '#ffff00';
|
||||
return '#ff0000';
|
||||
});
|
||||
|
||||
const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-'));
|
||||
const displayPenalty = computed(() => {
|
||||
const penalty = props.penaltyText ?? '-';
|
||||
const dedication = props.general?.progression?.dedicationText ?? '무품관';
|
||||
return `${penalty} · 계급 ${dedication}`;
|
||||
const displayTroop = computed<GeneralTroopDisplay | null>(() => {
|
||||
if (props.general?.troop) return props.general.troop;
|
||||
if (props.troopText && props.troopText !== '-') {
|
||||
return { name: props.troopText, status: 'present' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const displayDefence = computed(() => props.defenceText ?? '-');
|
||||
const displayPenalty = computed(() => {
|
||||
const refreshScore = props.general?.refreshScore;
|
||||
if (refreshScore) {
|
||||
return `${refreshScore.text} ${refreshScore.total.toLocaleString('ko-KR')}점(${refreshScore.current})`;
|
||||
}
|
||||
const penalty = props.penaltyText ?? '-';
|
||||
return String(penalty);
|
||||
});
|
||||
const displayDefence = computed(() => {
|
||||
if (props.general?.defenceTrain !== undefined) {
|
||||
return props.general.defenceTrain === 999
|
||||
? { text: '수비 안함', active: false }
|
||||
: { text: `수비 함(훈사${props.general.defenceTrain})`, active: true };
|
||||
}
|
||||
return { text: props.defenceText ?? '-', active: null };
|
||||
});
|
||||
const displayKillTurn = computed(() => props.general?.killTurn ?? props.killTurn);
|
||||
const displayRemainingMinutes = computed(() => props.general?.remainingMinutes ?? props.remainingMinutes);
|
||||
const specialText = computed(() => {
|
||||
const traits = props.general?.traits;
|
||||
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
|
||||
@@ -178,15 +227,20 @@ const specialText = computed(() => {
|
||||
:style="{ backgroundImage: generalIconBackground }"
|
||||
/>
|
||||
<div class="general-title battle-general-name" :style="titleStyle">
|
||||
{{ props.general.name }} 【 {{ props.general.officerLevelText }} |
|
||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】 다음 턴
|
||||
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
|
||||
{{ props.general.name }} 【
|
||||
<template v-if="props.general.officerCityName">{{ props.general.officerCityName }} </template>
|
||||
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
||||
<span data-general-turn-time>{{
|
||||
props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-for="stat of statRows" :key="stat.key">
|
||||
<span class="cell-label">{{ stat.label }}</span>
|
||||
<strong class="stat-value">
|
||||
<strong class="stat-value" :style="{ color: injuryInfo.color }">
|
||||
<span>{{ stat.value }}</span>
|
||||
<span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span>
|
||||
<span class="bar-cell" :data-stat-progress="stat.key">
|
||||
<LegacyProgressBar
|
||||
:percent="stat.percent"
|
||||
@@ -230,16 +284,32 @@ const specialText = computed(() => {
|
||||
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}세</strong>
|
||||
|
||||
<span class="cell-label defence-label">수비</span>
|
||||
<strong class="defence-value">{{ displayDefence }}</strong>
|
||||
<strong
|
||||
class="defence-value"
|
||||
:class="{
|
||||
'defence-value--active': displayDefence.active === true,
|
||||
'defence-value--inactive': displayDefence.active === false,
|
||||
}"
|
||||
>{{ displayDefence.text }}</strong
|
||||
>
|
||||
<span class="cell-label kill-label">삭턴</span>
|
||||
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn} 턴` }}</strong>
|
||||
<strong class="kill-value">{{ displayKillTurn === null ? '-' : `${displayKillTurn} 턴` }}</strong>
|
||||
<span class="cell-label execute-label">실행</span>
|
||||
<strong class="execute-value">{{
|
||||
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
|
||||
displayRemainingMinutes === null ? '-' : `${displayRemainingMinutes}분 남음`
|
||||
}}</strong>
|
||||
|
||||
<span class="cell-label troop-label">부대</span>
|
||||
<strong class="troop-value">{{ displayTroop }}</strong>
|
||||
<strong class="troop-value">
|
||||
<s v-if="displayTroop?.status === 'inactive'" class="troop-value--inactive">{{
|
||||
displayTroop.name
|
||||
}}</s>
|
||||
<span v-else-if="displayTroop?.status === 'away'" class="troop-value--away">
|
||||
{{ displayTroop.name
|
||||
}}<template v-if="displayTroop.leaderCityName">({{ displayTroop.leaderCityName }})</template>
|
||||
</span>
|
||||
<span v-else>{{ displayTroop?.name ?? '-' }}</span>
|
||||
</strong>
|
||||
<span class="cell-label penalty-label">벌점</span>
|
||||
<strong class="penalty-value">{{ displayPenalty }}</strong>
|
||||
</div>
|
||||
@@ -321,11 +391,19 @@ const specialText = computed(() => {
|
||||
|
||||
.stat-value {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
|
||||
grid-template-columns: minmax(22px, auto) auto minmax(26px, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.leadership-bonus {
|
||||
color: #00ffff;
|
||||
}
|
||||
|
||||
.stat-value > .bar-cell {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.bar-cell,
|
||||
.experience-bar {
|
||||
display: grid;
|
||||
@@ -413,6 +491,22 @@ const specialText = computed(() => {
|
||||
grid-row: 9;
|
||||
}
|
||||
|
||||
.defence-value--active {
|
||||
color: #32cd32;
|
||||
}
|
||||
|
||||
.defence-value--inactive {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.troop-value--inactive {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.troop-value--away {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
.general-loading,
|
||||
.empty {
|
||||
min-height: 192px;
|
||||
|
||||
@@ -4,3 +4,6 @@ export const formatSeoulDateTime = (value: string | Date): string => formatServe
|
||||
|
||||
export const formatSeoulHourMinute = (value: string | Date): string =>
|
||||
formatServerDateTime(value, { format: 'hourMinute' });
|
||||
|
||||
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
||||
formatServerDateTime(value, { format: 'timeSeconds' });
|
||||
|
||||
@@ -234,12 +234,7 @@ watch(
|
||||
<NationBasicCard :nation="nation" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 스탯" data-main-target="general">
|
||||
<GeneralBasicCard
|
||||
:general="general"
|
||||
:loading="loading"
|
||||
:nation-color="nation?.color"
|
||||
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
||||
/>
|
||||
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
|
||||
</PanelCard>
|
||||
<PanelCard title="도시 정보" data-main-target="city">
|
||||
<CityBasicCard :city="city" :loading="loading" />
|
||||
@@ -356,12 +351,7 @@ watch(
|
||||
<NationBasicCard :nation="nation" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 스탯" data-main-target="general">
|
||||
<GeneralBasicCard
|
||||
:general="general"
|
||||
:loading="loading"
|
||||
:nation-color="nation?.color"
|
||||
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
||||
/>
|
||||
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
|
||||
</PanelCard>
|
||||
<MainNationMenu
|
||||
class="nation-menu-middle"
|
||||
|
||||
@@ -382,7 +382,6 @@ onMounted(() => {
|
||||
:loading="loading"
|
||||
:nation-color="data?.nation?.color"
|
||||
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 함(훈사${form.defence_train})`"
|
||||
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
|
||||
:penalty-text="penalties.length || '-'"
|
||||
>
|
||||
<template v-if="data" #details>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { formatSeoulDateTime, formatSeoulHourMinute } from '../src/utils/legacyDateTime.ts';
|
||||
import { formatSeoulDateTime, formatSeoulHourMinute, formatSeoulTimeSeconds } from '../src/utils/legacyDateTime.ts';
|
||||
|
||||
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
||||
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
||||
assert.equal(formatSeoulHourMinute('2026-08-13T00:07:06.713Z'), '09:07');
|
||||
assert.equal(formatSeoulTimeSeconds('2026-08-13T00:07:06.713Z'), '09:07:06');
|
||||
});
|
||||
|
||||
void test('keeps legacy timezone-less server timestamps unchanged', () => {
|
||||
assert.equal(formatSeoulDateTime('2026-08-13 09:07:06'), '2026-08-13 09:07:06');
|
||||
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
||||
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user