V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import KsMenuSearch from '../KsMenuSearch.vue'
const entries = [
{ screenId: 'SCR-1', path: '/ops/data', module: 'Operations', section: 'Operations', title: '데이터 품질', order: 1, favoriteAllowed: true, internalOnly: false, permissions: [] },
{ screenId: 'SCR-2', path: '/portfolio/risk', module: 'Portfolio', section: 'Portfolio', title: '리스크', order: 1, favoriteAllowed: true, internalOnly: false, permissions: [] },
]
const global = { stubs: { KsDialog: { props: ['visible'], template: '<div><slot /></div>' } } }
describe('KsMenuSearch contract', () => {
it('connects the active result to the search input for assistive technology', async () => {
const wrapper = mount(KsMenuSearch, { props: { open: true, entries }, global })
await wrapper.vm.$nextTick()
const input = wrapper.get('input')
expect(input.attributes('aria-controls')).toBe('ks-menu-search-results')
expect(input.attributes('aria-activedescendant')).toBe('ks-menu-search-result-0')
expect(wrapper.get('#ks-menu-search-result-0').attributes('aria-selected')).toBe('true')
await input.trigger('keydown', { key: 'ArrowDown' })
expect(input.attributes('aria-activedescendant')).toBe('ks-menu-search-result-1')
})
it('removes the active descendant when filtering returns no results', async () => {
const wrapper = mount(KsMenuSearch, { props: { open: true, entries }, global })
const input = wrapper.get('input')
await input.setValue('없는 메뉴')
expect(input.attributes('aria-activedescendant')).toBeUndefined()
expect(wrapper.get('[role="listbox"]').text()).toContain('검색 결과가 없습니다.')
})
})
@@ -0,0 +1,33 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import KsSideNavigation from '../KsSideNavigation.vue'
const sections = [{ module: 'ModelOps', entries: [{ screenId: 'models', path: '/model-ops/models', module: 'ModelOps', section: 'ModelOps', title: 'Models', order: 1, favoriteAllowed: true, internalOnly: false, permissions: [] }] }]
const routerLinkStub = { props: ['to'], template: '<a :href="to"><slot /></a>' }
const global = { stubs: { RouterLink: routerLinkStub } }
describe('KsSideNavigation contract', () => {
it('marks a parent menu active for a nested detail route', () => {
const wrapper = mount(KsSideNavigation, { props: { sections, activePath: '/model-ops/models/model-1' }, global })
const link = wrapper.get('a')
expect(link.classes()).toContain('active')
expect(link.attributes('aria-current')).toBe('page')
})
it('does not mark an unrelated route active', () => {
const wrapper = mount(KsSideNavigation, { props: { sections, activePath: '/portfolio/risk' }, global })
expect(wrapper.get('a').classes()).not.toContain('active')
expect(wrapper.get('a').attributes('aria-current')).toBeUndefined()
})
it('collapses a module section without changing its navigation entries', async () => {
const wrapper = mount(KsSideNavigation, { props: { sections, activePath: '' }, global })
const toggle = wrapper.get('.ks-side-nav__section-toggle')
expect(toggle.attributes('aria-expanded')).toBe('true')
await toggle.trigger('click')
expect(wrapper.emitted('toggleSection')).toEqual([['ModelOps']])
await wrapper.setProps({ collapsedSections: ['ModelOps'] })
expect(wrapper.get('.ks-side-nav__section-toggle').attributes('aria-expanded')).toBe('false')
expect(wrapper.find('a').exists()).toBe(false)
})
})
@@ -31,6 +31,8 @@ describe('KsAppShell contract', () => {
expect(wrapper.get('aside').attributes('aria-label')).toBe('주요 메뉴')
expect(wrapper.get('nav.ks-workspace-tabs').attributes('aria-label')).toBe('열린 업무')
expect(wrapper.get('main').attributes('tabindex')).toBe('-1')
expect(wrapper.get('.ks-breadcrumb').attributes('aria-label')).toBe('현재 위치')
expect(wrapper.get('.ks-breadcrumb').text()).toContain('매도 의사결정')
expect(wrapper.text()).toContain('화면 내용')
expect(wrapper.text()).toContain('RESEARCH_CANDIDATE_NOT_PRODUCTION')
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildNavigationEntries, groupByModule } from '../navigationCatalog'
import { buildNavigationEntries, filterNavigationEntries, groupByModule } from '../navigationCatalog'
describe('navigation catalogue', () => {
it('includes the read-only component catalogue in the Design System menu', () => {
@@ -27,4 +27,27 @@ describe('navigation catalogue', () => {
])
expect(entries.find(entry => entry.path === '/internal/wbs')?.internalOnly).toBe(true)
})
it('preserves route permissions and filters navigation as a UI visibility hint', () => {
const entries = buildNavigationEntries([
{ path: '/models', meta: { screenId: 'models', module: 'ModelOps', title: 'Models', permissions: ['model.read'] } },
{ path: '/home', meta: { screenId: 'home', module: 'Home', title: '홈' } },
])
expect(entries[0].permissions).toEqual(['model.read'])
expect(filterNavigationEntries(entries, new Set())).toEqual([])
expect(filterNavigationEntries(entries, new Set(['model.read']))).toHaveLength(1)
})
it('fails closed for malformed permission metadata', () => {
const [entry] = buildNavigationEntries([{ path: '/safe', meta: { screenId: 'safe', module: 'Operations', permissions: ['ops.read', 7] } }])
expect(entry.permissions).toEqual([])
})
it('does not expose parameterized detail routes as top-level navigation', () => {
expect(buildNavigationEntries([
{ path: '/models', meta: { screenId: 'models', module: 'ModelOps', title: 'Models' } },
{ path: '/models/:modelId', meta: { screenId: 'model-detail', module: 'ModelOps', title: 'Model Detail' } },
])).toEqual([expect.objectContaining({ path: '/models' })])
})
})