diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 2def11c0..60bbe314 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -375,19 +375,52 @@ test('physical mobile touch reorders NPC priority across active and inactive lis } }); -test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { - const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; +test('a read-level user may edit drafts but cannot reset, revert, or submit them', async ({ page }) => { + const state: FixtureState = { permissionLevel: 1, mutations: [] }; await installFixture(page, state); await gotoPolicy(page); const input = page.getByLabel('국가 권장 금'); await expect(input).toBeEnabled(); await input.fill('23456'); - page.once('dialog', (dialog) => dialog.accept()); - await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); - await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + + const nationPanel = page.locator('.priority-panel').first(); + const activePriority = nationPanel.locator('.priority-column').nth(1).getByText('불가침제의'); + const inactivePriorityList = nationPanel.locator('.priority-column').first().locator('.priority-list'); + await activePriority.dragTo(inactivePriorityList); + await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible(); + + const actionButtons = page.locator('.control_bar button'); + await expect(actionButtons).toHaveCount(9); + for (const button of await actionButtons.all()) { + await expect(button).toBeDisabled(); + } + + const disabledStyle = await actionButtons.first().evaluate((element) => { + const style = getComputedStyle(element); + return { cursor: style.cursor, opacity: style.opacity, filter: style.filter }; + }); + expect(disabledStyle).toEqual({ cursor: 'not-allowed', opacity: '0.55', filter: 'none' }); + + // The handler guard is independent of the disabled DOM attribute. This also + // protects callers that dispatch a click after permission data has changed. + for (const button of await page.locator('#container > .control_bar button').all()) { + await button.evaluate((element) => { + element.removeAttribute('disabled'); + (element as HTMLButtonElement).click(); + }); + } + for (const button of await nationPanel.locator('.control_bar button').all()) { + await button.evaluate((element) => { + element.removeAttribute('disabled'); + (element as HTMLButtonElement).click(); + }); + } + await expect(input).toHaveValue('23456'); - expect(state.mutations).toEqual(['npc.setNationPolicy']); + await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible(); + expect(state.mutations).toEqual([]); + await screenshot(page, 'core-npc-policy-read-only-controls.png'); }); test('a user below secret read permission receives a recoverable page error', async ({ page }) => { diff --git a/app/game-frontend/src/views/NpcControlView.vue b/app/game-frontend/src/views/NpcControlView.vue index 4bf66f42..75489d82 100644 --- a/app/game-frontend/src/views/NpcControlView.vue +++ b/app/game-frontend/src/views/NpcControlView.vue @@ -46,6 +46,7 @@ const generalPriority = ref(null); const lastSavedNationPriority = ref([]); const lastSavedGeneralPriority = ref([]); const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback(); +const canManagePolicy = computed(() => (data.value?.permissionLevel ?? -1) >= 3); const resolveErrorMessage = (value: unknown): string => { if (value instanceof Error) return value.message; @@ -282,19 +283,19 @@ const priorityPanels = computed(() => { }); const resetPolicy = () => { - if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return; + if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return; policyDraft.value = clonePolicy(data.value.defaultNationPolicy); showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.'); }; const rollbackPolicy = () => { - if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return; + if (!canManagePolicy.value || !lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return; policyDraft.value = clonePolicy(lastSavedPolicy.value); showInfoToast('이전 설정으로 되돌렸습니다.'); }; const submitPolicy = async () => { - if (!policyDraft.value || !window.confirm('저장할까요?')) return; + if (!canManagePolicy.value || !policyDraft.value || !window.confirm('저장할까요?')) return; try { await trpc.npc.setNationPolicy.mutate(policyDraft.value); lastSavedPolicy.value = clonePolicy(policyDraft.value); @@ -305,7 +306,7 @@ const submitPolicy = async () => { }; const resetPriority = (section: PrioritySectionKey) => { - if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return; + if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return; if (section === 'nation') { nationPriority.value = assignPriorityState( data.value.defaultNationPriority, @@ -321,7 +322,7 @@ const resetPriority = (section: PrioritySectionKey) => { }; const rollbackPriority = (section: PrioritySectionKey) => { - if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return; + if (!canManagePolicy.value || !data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return; if (section === 'nation') { nationPriority.value = assignPriorityState( lastSavedNationPriority.value, @@ -338,7 +339,7 @@ const rollbackPriority = (section: PrioritySectionKey) => { const submitPriority = async (section: PrioritySectionKey) => { const state = section === 'nation' ? nationPriority.value : generalPriority.value; - if (!state || !window.confirm('저장할까요?')) return; + if (!canManagePolicy.value || !state || !window.confirm('저장할까요?')) return; try { if (section === 'nation') { await trpc.npc.setNationPriority.mutate(state.active); @@ -416,10 +417,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
- - + +
- +
@@ -498,14 +505,31 @@ const submitPriority = async (section: PrioritySectionKey) => {
- -
- +
@@ -728,10 +752,16 @@ const submitPriority = async (section: PrioritySectionKey) => { border-radius: 4px; } -.control_bar button:hover { +.control_bar button:not(:disabled):hover { filter: brightness(1.15); } +.control_bar button:disabled { + cursor: not-allowed; + opacity: 0.55; + filter: none; +} + .control_bar button:focus-visible, .help-button:focus-visible { outline: 2px solid #fff;