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
+23 -3
View File
@@ -16,6 +16,8 @@ const router = useRouter()
const preference = useScreenPreferenceStore()
const workspace = useWorkspaceStore()
const collapsed = ref(false)
const mobileNavOpen = ref(false)
const globalHeader = ref<InstanceType<typeof KsGlobalHeader> | null>(null)
const menuSearchOpen = ref(false)
const appVersion = import.meta.env.VITE_APP_VERSION ?? '0.1.0'
@@ -40,6 +42,10 @@ watch(() => route.fullPath, () => {
function goHome() {
router.push('/home')
}
function closeMobileNavigation() {
mobileNavOpen.value = false
requestAnimationFrame(() => globalHeader.value?.focusMenuButton())
}
function openMenuSearch() {
menuSearchOpen.value = true
}
@@ -60,6 +66,10 @@ function closeTab(tab: WorkspaceTab) {
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && mobileNavOpen.value) {
closeMobileNavigation()
return
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
menuSearchOpen.value = true
@@ -72,11 +82,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<template>
<div class="ks-app-shell">
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
<KsGlobalHeader :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" />
<KsGlobalHeader ref="globalHeader" :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" @menu-toggle="mobileNavOpen = true" />
<KsWorkspaceTabs :tabs="workspace.visibleTabs" :overflow-count="workspace.overflowCount" :active-key="activeTabKey()" @select="selectTab" @close="closeTab" @toggle-pin="workspace.togglePin" />
<div class="ks-app-shell__body">
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" @toggle-collapsed="collapsed = !collapsed" />
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" :collapsed-sections="preference.collapsedSectionModules" :mobile-open="mobileNavOpen" @toggle-collapsed="collapsed = !collapsed" @toggle-section="preference.toggleSection" @close-mobile="closeMobileNavigation" />
<button v-if="mobileNavOpen" type="button" class="ks-mobile-backdrop" aria-label="메뉴 닫기" @click="closeMobileNavigation" />
<main id="ks-main" class="ks-app-shell__main" tabindex="-1">
<nav class="ks-breadcrumb" aria-label="현재 위치">
<RouterLink to="/home"></RouterLink>
<span aria-hidden="true"></span>
<span aria-current="page">{{ typeof route.meta.title === 'string' ? route.meta.title : route.path }}</span>
</nav>
<slot />
</main>
</div>
@@ -90,9 +106,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
.ks-app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr auto; background: var(--ks-color-canvas); }
.ks-app-shell__body { display: flex; min-height: 0; }
.ks-app-shell__main { flex: 1; min-width: 0; padding: var(--ks-space-6); overflow: auto; }
.ks-breadcrumb { display: flex; gap: var(--ks-space-2); align-items: center; margin-bottom: var(--ks-space-4); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
.ks-breadcrumb a { color: inherit; text-decoration: none; }
.ks-breadcrumb a:hover, .ks-breadcrumb a:focus-visible { color: var(--ks-color-text); text-decoration: underline; }
.ks-app-shell__footer { padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-border); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
.ks-app-shell__version { position: fixed; right: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: rgb(255 255 255 / 92%); color: var(--ks-color-text-muted); font-size: .7rem; box-shadow: var(--ks-shadow-sm); }
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: var(--ks-color-surface); }
.ks-skip:focus { top: var(--ks-space-2); }
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } }
.ks-mobile-backdrop { display: none; }
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } .ks-mobile-backdrop { display: block; position: fixed; inset: 0; z-index: 40; border: 0; background: rgb(15 23 42 / 45%); } }
</style>
+7 -1
View File
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { ref } from 'vue'
withDefaults(defineProps<{ productName?: string; environment?: string; automationStatus?: string }>(), {
productName: 'K-ArtSell Aegis',
environment: 'IMPLEMENTATION_TEMPLATE',
automationStatus: '투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF'
})
const emit = defineEmits<{ home: []; menuSearch: [] }>()
const emit = defineEmits<{ home: []; menuSearch: []; menuToggle: [] }>()
const menuButton = ref<HTMLButtonElement | null>(null)
defineExpose({ focusMenuButton: () => menuButton.value?.focus() })
</script>
<template>
<header class="ks-global-header">
<button ref="menuButton" type="button" class="ks-global-header__menu" aria-label="주요 메뉴 열기" @click="emit('menuToggle')"></button>
<button type="button" class="ks-global-header__brand" @click="emit('home')">
<strong>{{ productName }}</strong><small>{{ environment }}</small>
</button>
@@ -27,4 +31,6 @@ const emit = defineEmits<{ home: []; menuSearch: [] }>()
.ks-global-header__search { flex: 1; max-width: 28rem; height: 2rem; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-2); padding: 0 var(--ks-space-3); border: 1px solid var(--ks-color-neutral-700); border-radius: var(--ks-radius-sm); background: var(--ks-color-neutral-800); color: var(--ks-color-neutral-300); }
.ks-global-header__search kbd { font-size: var(--ks-font-caption); border: 1px solid var(--ks-color-neutral-600); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-1); }
.ks-global-header__status { margin-left: auto; padding: var(--ks-space-1) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; font-size: var(--ks-font-caption); white-space: nowrap; }
.ks-global-header__menu { display: none; border: 1px solid var(--ks-color-neutral-700); border-radius: var(--ks-radius-sm); background: transparent; color: inherit; padding: .25rem .5rem; }
@media (max-width: 900px) { .ks-global-header__menu { display: inline-flex; } }
</style>
+4 -2
View File
@@ -9,6 +9,7 @@ const emit = defineEmits<{ close: []; select: [entry: NavigationEntry] }>()
const query = ref('')
const activeIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const resultId = (index: number) => `ks-menu-search-result-${index}`
const results = computed<NavigationEntry[]>(() => {
const q = query.value.trim().toLowerCase()
@@ -40,11 +41,12 @@ function selectActive() {
<template>
<KsDialog :visible="props.open" title="메뉴 검색" modal closable @update:visible="value => { if (!value) emit('close') }">
<div class="ks-menu-search" role="presentation" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="selectActive" @keydown.esc="emit('close')">
<input ref="inputRef" v-model="query" type="text" placeholder="메뉴명 · 화면코드 · 업무명 검색" aria-label="메뉴 검색" />
<ul role="listbox" aria-label="검색 결과">
<input ref="inputRef" v-model="query" type="text" placeholder="메뉴명 · 화면코드 · 업무명 검색" aria-label="메뉴 검색" :aria-activedescendant="results.length ? resultId(activeIndex) : undefined" aria-controls="ks-menu-search-results" />
<ul id="ks-menu-search-results" role="listbox" aria-label="검색 결과">
<li
v-for="(entry, index) in results"
:key="entry.screenId"
:id="resultId(index)"
role="option"
:aria-selected="index === activeIndex"
:class="{ active: index === activeIndex }"
+41 -5
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import type { NavigationEntry, NavigationSection } from './navigationCatalog'
@@ -7,23 +8,51 @@ const props = withDefaults(defineProps<{
favorites?: NavigationEntry[]
activePath?: string
collapsed?: boolean
collapsedSections?: readonly string[]
mobileOpen?: boolean
}>(), { favorites: () => [], activePath: '', collapsed: false })
const emit = defineEmits<{ toggleCollapsed: [] }>()
const emit = defineEmits<{ toggleCollapsed: []; toggleSection: [module: string]; closeMobile: [] }>()
const navigation = ref<HTMLElement | null>(null)
function isEntryActive(path: string): boolean {
return props.activePath === path || props.activePath.startsWith(`${path}/`)
}
function toggleSection(module: string): void {
emit('toggleSection', module)
}
function trapFocus(event: KeyboardEvent): void {
if (!props.mobileOpen || event.key !== 'Tab' || !navigation.value) return
const focusable = [...navigation.value.querySelectorAll<HTMLElement>('button, a')].filter(element => !element.hasAttribute('disabled'))
if (!focusable.length) return
const first = focusable[0]
const last = focusable.at(-1) ?? first
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
watch(() => props.mobileOpen, async open => {
if (open) { await nextTick(); navigation.value?.querySelector<HTMLElement>('.ks-side-nav__mobile-close')?.focus() }
})
</script>
<template>
<aside class="ks-side-nav" :class="{ 'ks-side-nav--collapsed': props.collapsed }" aria-label="주요 메뉴">
<aside ref="navigation" class="ks-side-nav" :class="{ 'ks-side-nav--collapsed': props.collapsed, 'ks-side-nav--mobile-open': props.mobileOpen }" aria-label="주요 메뉴" @keydown="trapFocus">
<button type="button" class="ks-side-nav__mobile-close" aria-label="주요 메뉴 닫기" @click="emit('closeMobile')">×</button>
<button type="button" class="ks-side-nav__toggle" :aria-expanded="!props.collapsed" @click="emit('toggleCollapsed')">
{{ props.collapsed ? '»' : '« 접기' }}
</button>
<template v-if="!props.collapsed">
<section v-if="favorites.length" class="ks-side-nav__section">
<h2>즐겨찾기</h2>
<RouterLink v-for="entry in favorites" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }"> {{ entry.title }}</RouterLink>
<RouterLink v-for="entry in favorites" :key="entry.screenId" :to="entry.path" :class="{ active: isEntryActive(entry.path) }" :aria-current="isEntryActive(entry.path) ? 'page' : undefined"> {{ entry.title }}</RouterLink>
</section>
<section v-for="section in sections" :key="section.module" class="ks-side-nav__section">
<h2>{{ section.module }}</h2>
<RouterLink v-for="entry in section.entries" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }">{{ entry.title }}</RouterLink>
<button type="button" class="ks-side-nav__section-toggle" :aria-expanded="!props.collapsedSections?.includes(section.module)" @click="toggleSection(section.module)">{{ section.module }}</button>
<template v-if="!props.collapsedSections?.includes(section.module)">
<RouterLink v-for="entry in section.entries" :key="entry.screenId" :to="entry.path" :class="{ active: isEntryActive(entry.path) }" :aria-current="isEntryActive(entry.path) ? 'page' : undefined">{{ entry.title }}</RouterLink>
</template>
</section>
</template>
</aside>
@@ -35,6 +64,13 @@ const emit = defineEmits<{ toggleCollapsed: [] }>()
.ks-side-nav__toggle { justify-self: end; border: 0; background: transparent; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); cursor: pointer; }
.ks-side-nav__section { display: grid; gap: var(--ks-space-1); }
.ks-side-nav__section h2 { margin: 0 var(--ks-space-2); font-size: var(--ks-font-caption); font-weight: 600; color: var(--ks-color-text-muted); text-transform: uppercase; }
.ks-side-nav__section-toggle { border: 0; background: transparent; text-align: left; margin: 0 var(--ks-space-2); padding: 0; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); font-weight: 600; text-transform: uppercase; cursor: pointer; }
.ks-side-nav__section a { padding: var(--ks-space-2) var(--ks-space-2); border-radius: var(--ks-radius-sm); text-decoration: none; color: var(--ks-color-text); font-size: var(--ks-font-body); }
.ks-side-nav__section a.active, .ks-side-nav__section a.router-link-active { background: var(--ks-color-neutral-100); font-weight: 700; }
.ks-side-nav__mobile-close { display: none; }
@media (max-width: 900px) {
.ks-side-nav { position: fixed; inset: 0 auto 0 0; z-index: 50; width: min(20rem, 88vw); transform: translateX(-105%); transition: transform .18s ease; box-shadow: var(--ks-shadow-lg); }
.ks-side-nav--mobile-open { transform: translateX(0); }
.ks-side-nav__mobile-close { display: block; justify-self: end; border: 0; background: transparent; font-size: 1.5rem; cursor: pointer; }
}
</style>
+11 -1
View File
@@ -12,6 +12,7 @@ export interface NavigationEntry {
order: number
favoriteAllowed: boolean
internalOnly: boolean
permissions: readonly string[]
}
export interface NavigationSection {
@@ -23,6 +24,7 @@ function toEntry(route: NavigationRouteLike): NavigationEntry | null {
const meta = route.meta
if (!meta || typeof meta.screenId !== 'string' || typeof meta.module !== 'string') return null
if (meta.module === 'Home') return null
if (route.path.includes(':')) return null
return {
screenId: meta.screenId,
path: route.path,
@@ -31,10 +33,18 @@ function toEntry(route: NavigationRouteLike): NavigationEntry | null {
title: typeof meta.title === 'string' ? meta.title : route.path,
order: typeof meta.order === 'number' ? meta.order : 0,
favoriteAllowed: meta.favoriteAllowed !== false,
internalOnly: meta.internalOnly === true
internalOnly: meta.internalOnly === true,
permissions: Array.isArray(meta.permissions) && meta.permissions.every(permission => typeof permission === 'string')
? meta.permissions
: []
}
}
/** UI visibility hint only; the API remains the authorization authority. */
export function filterNavigationEntries(entries: readonly NavigationEntry[], grantedPermissions: ReadonlySet<string>): NavigationEntry[] {
return entries.filter(entry => entry.permissions.every(permission => grantedPermissions.has(permission)))
}
/** router.getRoutes()의 flat route 목록에서 즐겨찾기/검색/사이드바가 공유하는 단일 카탈로그를 파생한다. */
export function buildNavigationEntries(routes: readonly NavigationRouteLike[]): NavigationEntry[] {
const entries = routes.map(toEntry).filter((entry): entry is NavigationEntry => entry !== null)
@@ -13,20 +13,24 @@ const MAX_FAVORITES = 20
interface PersistedShape {
favoriteScreenIds: string[]
recents: RecentNavigation[]
collapsedSectionModules: string[]
}
function readPersisted(): PersistedShape {
if (typeof window === 'undefined') return { favoriteScreenIds: [], recents: [] }
if (typeof window === 'undefined') return { favoriteScreenIds: [], recents: [], collapsedSectionModules: [] }
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return { favoriteScreenIds: [], recents: [] }
if (!raw) return { favoriteScreenIds: [], recents: [], collapsedSectionModules: [] }
const parsed = JSON.parse(raw) as Partial<PersistedShape>
return {
favoriteScreenIds: Array.isArray(parsed.favoriteScreenIds) ? parsed.favoriteScreenIds.slice(0, MAX_FAVORITES) : [],
recents: Array.isArray(parsed.recents) ? parsed.recents.slice(0, MAX_RECENTS) : []
recents: Array.isArray(parsed.recents) ? parsed.recents.slice(0, MAX_RECENTS) : [],
collapsedSectionModules: Array.isArray(parsed.collapsedSectionModules)
? parsed.collapsedSectionModules.filter((module): module is string => typeof module === 'string')
: []
}
} catch {
return { favoriteScreenIds: [], recents: [] }
return { favoriteScreenIds: [], recents: [], collapsedSectionModules: [] }
}
}
@@ -35,7 +39,7 @@ export const useScreenPreferenceStore = defineStore('ks-screen-preference', {
actions: {
persist() {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ favoriteScreenIds: this.favoriteScreenIds, recents: this.recents }))
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ favoriteScreenIds: this.favoriteScreenIds, recents: this.recents, collapsedSectionModules: this.collapsedSectionModules }))
},
toggleFavorite(screenId: string) {
const index = this.favoriteScreenIds.indexOf(screenId)
@@ -50,6 +54,15 @@ export const useScreenPreferenceStore = defineStore('ks-screen-preference', {
const withoutCurrent = this.recents.filter(entry => entry.screenId !== screenId)
this.recents = [{ screenId, path, visitedAt: new Date().toISOString() }, ...withoutCurrent].slice(0, MAX_RECENTS)
this.persist()
},
toggleSection(module: string) {
const index = this.collapsedSectionModules.indexOf(module)
if (index >= 0) this.collapsedSectionModules.splice(index, 1)
else this.collapsedSectionModules.push(module)
this.persist()
},
isSectionCollapsed(module: string): boolean {
return this.collapsedSectionModules.includes(module)
}
}
})
@@ -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' })])
})
})