merge: align main menu and NPC reply affordance

This commit is contained in:
2026-08-12 15:48:09 +00:00
4 changed files with 152 additions and 3 deletions
+99 -2
View File
@@ -32,6 +32,8 @@ type NavigationFixture = {
refreshDelayMs?: number; refreshDelayMs?: number;
largeCommandTable?: boolean; largeCommandTable?: boolean;
reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>; reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>;
messages?: unknown;
messageContacts?: unknown;
dashboardResponses?: Array<{ dashboardResponses?: Array<{
bytes: number; bytes: number;
contextKind: string | null; contextKind: string | null;
@@ -405,8 +407,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response({ turns: state.reservedTurns ?? [], revision: 0 }); return response({ turns: state.reservedTurns ?? [], revision: 0 });
} }
if (operation === 'messages.getRecent') return response(emptyMessages(state.permission)); if (operation === 'messages.getRecent') {
if (operation === 'messages.getContacts') return response({ nation: [] }); return response(state.messages ?? emptyMessages(state.permission));
}
if (operation === 'messages.getContacts') return response(state.messageContacts ?? { nation: [] });
if (operation === 'general.getRecentRecords') { if (operation === 'general.getRecentRecords') {
return response({ return response({
global: [{ id: 3, text: '장수 동향 기록' }], global: [{ id: 3, text: '장수 동향 기록' }],
@@ -617,7 +621,23 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
const gameInfoButton = global.locator('[data-menu-id="game-info"]'); const gameInfoButton = global.locator('[data-menu-id="game-info"]');
const bettingButton = global.locator('[data-navigation-id="nation-betting"]');
await expect
.poll(() =>
bettingButton.evaluate((element) => ({
backgroundColor: getComputedStyle(element).backgroundColor,
backgroundImage: getComputedStyle(element).backgroundImage,
}))
)
.toEqual({ backgroundColor: 'rgb(0, 88, 44)', backgroundImage: 'none' });
await bettingButton.hover();
await expect
.poll(() => bettingButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe('rgb(0, 88, 44)');
await gameInfoButton.focus(); await gameInfoButton.focus();
await expect
.poll(() => gameInfoButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe('rgb(0, 88, 44)');
await gameInfoButton.press('Enter'); await gameInfoButton.press('Enter');
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
await expect(global.locator('#global-menu-game-info')).toBeVisible(); await expect(global.locator('#global-menu-game-info')).toBeVisible();
@@ -631,6 +651,74 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
}); });
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
const target = (generalId: number, generalName: string) => ({
generalId,
generalName,
nationId: 1,
nationName: '위',
color: '#008000',
icon: '',
});
const messages = {
...emptyMessages(0),
public: [
{
id: 102,
text: 'NPC 메시지',
time: '2026-08-12 12:00:00',
msgType: 'public',
src: target(22, '순수NPC'),
dest: null,
option: {},
},
{
id: 101,
text: '유저 메시지',
time: '2026-08-12 11:59:00',
msgType: 'public',
src: target(21, '유저장수'),
dest: null,
option: {},
},
],
};
const state: NavigationFixture = {
officerLevel: 1,
permission: 0,
nationLevel: 1,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
messages,
messageContacts: {
nation: [
{
nationId: 1,
mailbox: 9001,
name: '위',
color: '#008000',
general: [[21, '유저장수', 0]],
},
],
},
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const npcMessage = page.locator('.desktop-message-panel .msg-plate[data-id="102"]');
const userMessage = page.locator('.desktop-message-panel .msg-plate[data-id="101"]');
await expect(npcMessage.locator('.msg-header')).toContainText('순수NPC:위');
await expect(npcMessage.locator('.msg-header')).not.toContainText('↩');
await expect(npcMessage.getByRole('button', { name: /순수NPC/ })).toHaveCount(0);
await expect(userMessage.getByRole('button', { name: /유저장수:위.*↩/ })).toBeVisible();
await userMessage.getByRole('button', { name: /유저장수:위.*↩/ }).click();
await expect(page.locator('.desktop-message-panel #mailbox_list')).toHaveValue('21');
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
});
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 1, officerLevel: 1,
@@ -925,6 +1013,15 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await expect(page.locator('.main-mobile-bottom')).toBeVisible();
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect
.poll(() =>
page
.locator('.main-global-menu')
.first()
.locator('[data-navigation-id="nation-betting"]')
.evaluate((element) => getComputedStyle(element).backgroundColor)
)
.toBe('rgb(0, 88, 44)');
const documentGeometry = await page.locator('.main-page').evaluate((element) => { const documentGeometry = await page.locator('.main-page').evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return { return {
@@ -115,6 +115,33 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
min-width: 0; min-width: 0;
} }
.main-global-menu > :deep(.main-menu-link),
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link),
.main-menu-split > .main-menu-split__toggle {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
background-image: none;
}
.main-global-menu > :deep(.main-menu-link:hover),
.main-global-menu > :deep(.main-menu-link:focus-visible),
.main-global-menu > :deep(.main-menu-link:active),
.main-menu-popup > .main-menu-button:hover,
.main-menu-popup > .main-menu-button:focus-visible,
.main-menu-popup > .main-menu-button:active,
.main-menu-popup > .main-menu-button[aria-expanded='true'],
.main-menu-split > :deep(.main-menu-link:hover),
.main-menu-split > :deep(.main-menu-link:focus-visible),
.main-menu-split > :deep(.main-menu-link:active),
.main-menu-split > .main-menu-split__toggle:hover,
.main-menu-split > .main-menu-split__toggle:focus-visible,
.main-menu-split > .main-menu-split__toggle:active,
.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
}
.main-menu-popup > .main-menu-button, .main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) { .main-menu-split > :deep(.main-menu-link) {
width: 100%; width: 100%;
@@ -87,6 +87,13 @@ const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ??
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]); const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
const permission = computed(() => props.messages?.permission ?? -1); const permission = computed(() => props.messages?.permission ?? -1);
const replyableGeneralIds = computed(() =>
props.mailboxGroups.flatMap((group) =>
group.options
.filter((option) => !option.disabled && option.value > 0 && option.value < 9000)
.map((option) => option.value)
)
);
const setMailbox = (value: string) => { const setMailbox = (value: string) => {
const parsed = Number(value); const parsed = Number(value);
@@ -239,6 +246,7 @@ const forwardResponse = (messageId: number, response: boolean) => {
:nation-id="nationId" :nation-id="nationId"
:permission="permission" :permission="permission"
:can-respond-diplomacy="canRespondDiplomacy" :can-respond-diplomacy="canRespondDiplomacy"
:replyable-general-ids="replyableGeneralIds"
@set-target="setReplyTarget" @set-target="setReplyTarget"
@delete="emit('delete', $event)" @delete="emit('delete', $event)"
@respond="forwardResponse" @respond="forwardResponse"
@@ -29,6 +29,7 @@ const props = defineProps<{
nationId: number; nationId: number;
permission: number; permission: number;
canRespondDiplomacy: boolean; canRespondDiplomacy: boolean;
replyableGeneralIds: number[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -107,6 +108,7 @@ const isBright = (color: string): boolean => {
}; };
const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon)); const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon));
const canReplyToGeneral = (target: MessageTarget): boolean => props.replyableGeneralIds.includes(target.generalId);
const targetClass = (target: MessageTarget) => ({ const targetClass = (target: MessageTarget) => ({
'msg-target': true, 'msg-target': true,
@@ -167,6 +169,7 @@ onBeforeUnmount(() => {
> >
<span class="msg-from-to"></span> <span class="msg-from-to"></span>
<button <button
v-if="canReplyToGeneral(destination)"
:class="targetClass(destination)" :class="targetClass(destination)"
:style="{ backgroundColor: destination.color }" :style="{ backgroundColor: destination.color }"
type="button" type="button"
@@ -174,9 +177,13 @@ onBeforeUnmount(() => {
> >
{{ destination.generalName }}:{{ destination.nationName }} | {{ destination.generalName }}:{{ destination.nationName }} |
</button> </button>
<span v-else :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.generalName }}:{{ destination.nationName }}
</span>
</template> </template>
<template v-else> <template v-else>
<button <button
v-if="canReplyToGeneral(message.src)"
:class="targetClass(message.src)" :class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }" :style="{ backgroundColor: message.src.color }"
type="button" type="button"
@@ -184,6 +191,9 @@ onBeforeUnmount(() => {
> >
{{ message.src.generalName }}:{{ message.src.nationName }} | {{ message.src.generalName }}:{{ message.src.nationName }} |
</button> </button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
<span class="msg-from-to"></span> <span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }" <span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span ></span
@@ -241,7 +251,7 @@ onBeforeUnmount(() => {
</template> </template>
<button <button
v-else-if="message.src.generalId !== generalId" v-else-if="message.src.generalId !== generalId && canReplyToGeneral(message.src)"
:class="targetClass(message.src)" :class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }" :style="{ backgroundColor: message.src.color }"
type="button" type="button"
@@ -249,6 +259,13 @@ onBeforeUnmount(() => {
> >
{{ message.src.generalName }}:{{ message.src.nationName }} | {{ message.src.generalName }}:{{ message.src.nationName }} |
</button> </button>
<span
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
>
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"> <span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }} {{ message.src.generalName }}
</span> </span>