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
+3 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios'
import { ApiProblem, type ProblemDetails } from './problem'
import { ApiProblem, problemDetailsSchema } from './problem'
export const api = axios.create({ baseURL: '/api', timeout: 15_000 })
@@ -16,8 +16,8 @@ api.interceptors.request.use(config => {
api.interceptors.response.use(
response => response,
error => {
const data = error.response?.data as ProblemDetails | undefined
if (data?.status) throw new ApiProblem(data)
const parsed = problemDetailsSchema.safeParse(error.response?.data)
if (parsed.success) throw new ApiProblem(parsed.data)
throw error
}
)
+47
View File
@@ -1,10 +1,57 @@
import { z } from 'zod'
export const problemTypeSchema = z.enum([
'validation', 'business-rule', 'conflict', 'permission', 'not-found', 'integration', 'system',
])
const validationErrorSchema = z.object({
field: z.string().nullable().optional(),
rowKey: z.string().nullable().optional(),
code: z.string().min(1),
message: z.string().min(1),
}).strict()
const problemActionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) }).strict()
export interface ProblemDetails {
type?: string
title: string
status: number
detail?: string
traceId?: string
correlationId?: string
code?: string
retryable?: boolean
currentVersion?: number | null
actions?: Array<{ id: string; label: string }>
errors?: Record<string, string[]>
validationErrors?: Array<{ field?: string | null; rowKey?: string | null; code: string; message: string }>
}
export const problemDetailsSchema = z.object({
type: z.string().optional(),
title: z.string().min(1),
status: z.number().int().min(400).max(599),
detail: z.string().optional(),
traceId: z.string().optional(),
correlationId: z.string().optional(),
code: z.string().min(1).optional(),
retryable: z.boolean().optional(),
currentVersion: z.number().int().min(1).nullable().optional(),
actions: z.array(problemActionSchema).optional(),
errors: z.record(z.string(), z.array(z.string())).optional(),
validationErrors: z.array(validationErrorSchema).optional(),
}).strict()
export type ProblemInteraction = 'UNAUTHORIZED' | 'FORBIDDEN' | 'CONFLICT' | 'VALIDATION' | 'RETRYABLE' | 'ERROR'
export function resolveProblemInteraction(problem: Pick<ProblemDetails, 'status' | 'type' | 'retryable'>): ProblemInteraction {
if (problem.status === 401) return 'UNAUTHORIZED'
if (problem.status === 403 || problem.type === 'permission') return 'FORBIDDEN'
if (problem.status === 409 || problem.type === 'conflict') return 'CONFLICT'
if (problem.status === 422 || problem.type === 'validation') return 'VALIDATION'
if (problem.retryable === true || problem.type === 'integration' || problem.status === 429 || problem.status === 503) return 'RETRYABLE'
return 'ERROR'
}
export class ApiProblem extends Error {
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { problemDetailsSchema, resolveProblemInteraction } from '../problem'
describe('ProblemDetails runtime contract', () => {
it('accepts a structured API problem', () => {
expect(problemDetailsSchema.safeParse({
type: 'https://example.test/problems/validation',
title: 'Validation failed',
status: 422,
detail: 'One or more fields are invalid.',
traceId: 'trace-1',
errors: { name: ['Required'] },
}).success).toBe(true)
})
it('rejects malformed or non-HTTP error payloads', () => {
expect(problemDetailsSchema.safeParse({ title: 'Bad', status: 200 }).success).toBe(false)
expect(problemDetailsSchema.safeParse({ title: 'Bad', status: 500, unexpected: true }).success).toBe(false)
})
it('accepts KBX-derived discriminators without changing the HTTP boundary', () => {
expect(problemDetailsSchema.safeParse({
type: 'conflict', title: 'Version conflict', status: 409,
correlationId: 'corr-1', currentVersion: 4,
actions: [{ id: 'reload', label: '새로 고침' }], retryable: false,
}).success).toBe(true)
})
it.each([
[401, undefined, 'UNAUTHORIZED'], [403, 'permission', 'FORBIDDEN'],
[409, 'conflict', 'CONFLICT'], [422, 'validation', 'VALIDATION'],
[503, 'integration', 'RETRYABLE'], [500, 'system', 'ERROR'],
] as const)('maps %s to the standard interaction boundary', (status, type, expected) => {
expect(resolveProblemInteraction({ status, type, retryable: false })).toBe(expected)
})
})
+9
View File
@@ -0,0 +1,9 @@
export type RouteAccessMeta = {
readonly permissions?: readonly string[]
}
/** UI visibility hint only; the API remains the authorization authority. */
export function canAccessRoute(meta: RouteAccessMeta, grantedPermissions: ReadonlySet<string>): boolean {
const required = meta.permissions ?? []
return required.every(permission => grantedPermissions.has(permission))
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { canAccessRoute } from '../routeAccess'
import { router } from '../../../app/router'
import { modelsDetailScreen, modelsListScreen } from '../../../features/models/registry'
import { shadowRunDetailScreen, shadowRunListScreen } from '../../../features/shadow-run/registry'
describe('route access contract', () => {
it('allows routes without a declared permission', () => {
expect(canAccessRoute({}, new Set())).toBe(true)
})
it('requires every declared permission', () => {
expect(canAccessRoute({ permissions: ['model.read'] }, new Set())).toBe(false)
expect(canAccessRoute({ permissions: ['model.read'] }, new Set(['model.read']))).toBe(true)
})
it('keeps active ModelOps route metadata aligned with feature registries', () => {
const registered = [modelsListScreen, modelsDetailScreen, shadowRunListScreen, shadowRunDetailScreen]
for (const screen of registered) {
const route = router.getRoutes().find(candidate => candidate.path === screen.path)
expect(route?.meta.permissions).toEqual(screen.permissions)
}
})
})
@@ -0,0 +1,32 @@
import type { ZodType } from 'zod'
import type { ProblemDetails } from '../api/problem'
export interface FormValidationFailure {
summary: string
fields: Record<string, string[]>
}
export type FormValidationResult<T> =
| { success: true; data: T }
| { success: false; error: FormValidationFailure }
/** Parse at the feature submit boundary; the generic screen shell stays presentation-only. */
export function validateFormSubmission<T>(schema: ZodType<T>, input: unknown): FormValidationResult<T> {
const result = schema.safeParse(input)
if (result.success) return result
const fields: Record<string, string[]> = {}
for (const issue of result.error.issues) {
const field = issue.path.length > 0 ? issue.path.join('.') : '_form'
fields[field] ??= []
fields[field].push(issue.message)
}
return { success: false, error: { summary: '입력값을 확인해 주세요.', fields } }
}
export function mapProblemToFormFailure(problem: Pick<ProblemDetails, 'title' | 'detail' | 'errors'>): FormValidationFailure {
return {
summary: problem.detail?.trim() || problem.title,
fields: Object.fromEntries(Object.entries(problem.errors ?? {}).map(([field, messages]) => [field, [...messages]]))
}
}
+13 -3
View File
@@ -5,12 +5,18 @@ const positiveInt = (value: string | null, fallback: number): number => {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
export interface CrudQueryCodecOptions {
readonly allowedSortFields?: ReadonlySet<string>
readonly allowedFilterFields?: ReadonlySet<string>
readonly allowedFilterOperators?: ReadonlySet<string>
}
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery, options: CrudQueryCodecOptions = {}): CrudListQuery {
const sorts = params.getAll('sort').flatMap(value => {
const parts = value.split(':')
const field = parts[0]
const direction = parts[1]
if (field && (direction === 'asc' || direction === 'desc')) {
if (field && (direction === 'asc' || direction === 'desc') && (!options.allowedSortFields || options.allowedSortFields.has(field))) {
return [{ field, direction: direction as 'asc' | 'desc' }]
}
return []
@@ -19,7 +25,11 @@ export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery
const first = value.indexOf(':')
const second = value.indexOf(':', first + 1)
if (first <= 0 || second <= first) return []
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
const field = value.slice(0, first)
const operator = value.slice(first + 1, second)
if (options.allowedFilterFields && !options.allowedFilterFields.has(field)) return []
if (options.allowedFilterOperators && !options.allowedFilterOperators.has(operator)) return []
return [{ field, operator, value: value.slice(second + 1) }]
})
return {
page: positiveInt(params.get('page'), fallback.page),
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { mapProblemToFormFailure, validateFormSubmission } from '../formValidation'
const schema = z.object({ name: z.string().min(1, '이름은 필수입니다.'), quantity: z.number().int().positive('수량은 양수여야 합니다.') })
describe('validateFormSubmission', () => {
it('returns typed data for valid input', () => {
expect(validateFormSubmission(schema, { name: 'item', quantity: 2 })).toEqual({ success: true, data: { name: 'item', quantity: 2 } })
})
it('maps field errors and summary for invalid input', () => {
const result = validateFormSubmission(schema, { name: '', quantity: 0 })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.summary).toBe('입력값을 확인해 주세요.')
expect(result.error.fields.name).toContain('이름은 필수입니다.')
expect(result.error.fields.quantity).toContain('수량은 양수여야 합니다.')
}
})
it('uses a form-level bucket for root validation errors', () => {
const result = validateFormSubmission(z.string().min(3, '너무 짧습니다.'), 'x')
expect(result.success).toBe(false)
if (!result.success) expect(result.error.fields._form).toContain('너무 짧습니다.')
})
it('maps server ProblemDetails field errors without mutating the response', () => {
const problem = { title: '검증 실패', detail: '입력값 오류', errors: { name: ['중복 이름'] } }
const mapped = mapProblemToFormFailure(problem)
expect(mapped).toEqual({ summary: '입력값 오류', fields: { name: ['중복 이름'] } })
expect(mapped.fields.name).not.toBe(problem.errors.name)
})
})
@@ -9,4 +9,18 @@ describe('CRUD URL codec', () => {
it('fails closed to approved defaults for invalid paging', () => {
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
})
it('drops unapproved sort fields, filter fields, and operators when a whitelist is supplied', () => {
const params = new URLSearchParams('sort=secret:desc&sort=asOf:asc&filter=secret:eq:x&filter=status:contains:READY&filter=status:eq:READY')
expect(decodeCrudQuery(params, fallback, {
allowedSortFields: new Set(['asOf']),
allowedFilterFields: new Set(['status']),
allowedFilterOperators: new Set(['eq'])
})).toEqual({
...fallback,
sorts: [{ field: 'asOf', direction: 'asc' }],
filters: [{ field: 'status', operator: 'eq', value: 'READY' }],
search: undefined
})
})
})
@@ -1,7 +1,16 @@
import { describe, expect, it } from 'vitest'
import { formatCurrency, formatPercent, formatQuantity } from '../financial'
import { formatAsOf, formatCurrency, formatPercent, formatQuantity } from '../financial'
describe('financial formatters', () => {
it('renders missing values as an explicit em dash', () => { expect(formatCurrency(null, 'KRW')).toBe('—'); expect(formatPercent(undefined)).toBe('—') })
it('keeps percentage inputs in decimal-return units', () => { expect(formatPercent(0.125, 1)).toContain('12.5') })
it('uses bounded quantity precision', () => { expect(formatQuantity(1.234567, 2)).toContain('1.23') })
it('requires an explicit currency and preserves the currency marker', () => {
expect(formatCurrency(1234.5, 'KRW')).toContain('₩')
})
it('formats valid as-of instants in the display timezone and rejects invalid values', () => {
expect(formatAsOf('2026-08-12T00:00:00Z')).toContain('2026')
expect(formatAsOf('not-a-date')).toBe('—')
})
})
+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' })])
})
})
@@ -1,17 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatAsOf } from '../formatters/financial'
const props = defineProps<{
asOf: string
staleAfterMinutes: number
now: string
source?: string
revision?: number
refreshable?: boolean
}>()
const emit = defineEmits<{ refresh: [] }>()
const ageMinutes = computed(() => Math.max(0, (Date.now() - new Date(props.asOf).getTime()) / 60_000))
const ageMinutes = computed(() => Math.max(0, (new Date(props.now).getTime() - new Date(props.asOf).getTime()) / 60_000))
const stale = computed(() => ageMinutes.value > props.staleAfterMinutes)
</script>
<template>
<span :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'">
{{ stale ? 'STALE' : 'FRESH' }} · {{ new Date(props.asOf).toLocaleString() }}
<button v-if="props.refreshable" type="button" :aria-label="stale ? '데이터 지연, 새로 고침' : '데이터 최신, 새로 고침'" :data-status="stale ? 'stale' : 'fresh'" :title="props.source ? `출처: ${props.source}` : undefined" @click="emit('refresh')">
<span aria-hidden="true"></span> {{ stale ? 'STALE' : 'FRESH' }} · {{ formatAsOf(props.asOf) }}<span v-if="props.revision !== undefined"> · rev {{ props.revision }}</span>
</button>
<span v-else :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'" :title="props.source ? `출처: ${props.source}` : undefined">
{{ stale ? 'STALE' : 'FRESH' }} · {{ formatAsOf(props.asOf) }}<span v-if="props.revision !== undefined"> · rev {{ props.revision }}</span>
</span>
</template>
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DataFreshnessBadge from '../DataFreshnessBadge.vue'
describe('DataFreshnessBadge', () => {
const base = { asOf: '2026-08-12T00:00:00Z', staleAfterMinutes: 30, now: '2026-08-12T00:29:00Z' }
it('uses the injected instant instead of the machine clock', () => {
const wrapper = mount(DataFreshnessBadge, { props: base })
expect(wrapper.attributes('data-status')).toBe('fresh')
})
it('marks data stale only after the configured boundary', () => {
const wrapper = mount(DataFreshnessBadge, { props: { ...base, now: '2026-08-12T00:30:01Z' } })
expect(wrapper.attributes('data-status')).toBe('stale')
expect(wrapper.attributes('aria-label')).toBe('데이터 지연')
})
it('exposes source/revision and emits an explicit refresh command only when enabled', async () => {
const wrapper = mount(DataFreshnessBadge, { props: { ...base, source: 'approved-read-model', revision: 3, refreshable: true } })
expect(wrapper.text()).toContain('rev 3')
expect(wrapper.attributes('title')).toContain('approved-read-model')
await wrapper.trigger('click')
expect(wrapper.emitted('refresh')).toHaveLength(1)
})
})
+13 -2
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
import type { UiGridColumn } from './adapter/contracts'
import { KsDataGrid } from './components'
import { KsDataGrid, KsPaginator } from './components'
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
rows: unknown[]
columns: UiGridColumn[]
loading?: boolean
emptyMessage?: string
page?: number
pageSize?: number
total?: number
}>(), { loading: false, emptyMessage: '표시할 데이터가 없습니다.' })
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
</script>
<template>
@@ -15,5 +19,12 @@ withDefaults(defineProps<{
<p v-if="loading">데이터를 불러오는 중입니다.</p>
<p v-else-if="rows.length === 0">{{ emptyMessage }}</p>
<KsDataGrid v-else :rows="rows" :columns="columns" height="30rem" />
<KsPaginator
v-if="props.page !== undefined && props.pageSize !== undefined && props.total !== undefined"
:page="props.page"
:page-size="props.pageSize"
:total="props.total"
@page-change="emit('pageChange', $event)"
/>
</section>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { UiSeverity } from '../contracts'
withDefaults(defineProps<{ value: string; severity?: UiSeverity }>(), { severity: 'info' })
import type { UiGridStatusSemantic } from '../../gridStatus'
withDefaults(defineProps<{ value: string; severity?: UiSeverity; semantic?: UiGridStatusSemantic; unknown?: boolean }>(), { severity: 'info', unknown: false })
</script>
<template><span class="ks-native-tag" :class="`is-${severity}`">{{ value }}</span></template>
<template><span class="ks-native-tag" :class="[`is-${severity}`, { 'is-unknown': unknown }]" :data-semantic="semantic" :aria-label="unknown ? `정의되지 않음: ${value}` : value"><span aria-hidden="true">{{ unknown ? '?' : '●' }}</span> {{ value }}</span></template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
import type { UiTextFieldType } from '../contracts'
defineProps<{ modelValue: string; inputId?: string; type?: UiTextFieldType; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
</script>
<template><input :id="inputId" class="ks-native-input" type="text" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @blur="emit('blur', $event)" /></template>
<template><input :id="inputId" class="ks-native-input" :type="type ?? 'text'" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @blur="emit('blur', $event)" /></template>
@@ -1,13 +1,14 @@
<script setup lang="ts">
import Tag from 'primevue/tag'
import type { UiSeverity } from '../contracts'
import type { UiGridStatusSemantic } from '../../gridStatus'
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string; semantic?: UiGridStatusSemantic; unknown?: boolean }>()
</script>
<template>
<Tag class="ks-status-tag" :severity="severity ?? 'info'">
<span v-if="iconLabel" aria-hidden="true">{{ iconLabel }}</span>
<Tag class="ks-status-tag" :severity="severity ?? 'info'" :data-semantic="semantic" :data-unknown="unknown || undefined" :aria-label="unknown ? `정의되지 않음: ${value}` : value">
<span aria-hidden="true">{{ iconLabel ?? (unknown ? '?' : '●') }}</span>
<span>{{ value }}</span>
</Tag>
</template>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import type { UiTextFieldType } from '../contracts'
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
defineProps<{ modelValue: string; inputId?: string; type?: UiTextFieldType; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
</script>
@@ -10,6 +11,7 @@ defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>
class="ks-input"
:id="inputId"
:model-value="modelValue"
:type="type"
:disabled="disabled"
:invalid="invalid"
:placeholder="placeholder"
@@ -1,18 +1,20 @@
import { defineAsyncComponent, type Component } from 'vue'
import type { UiAdapter, UiAdapterCapability } from '../contracts'
import Button from './PrimeButtonAdapter.vue'
import TextField from './PrimeTextFieldAdapter.vue'
import TextArea from './PrimeTextAreaAdapter.vue'
import Select from './PrimeSelectAdapter.vue'
import MultiSelect from './PrimeMultiSelectAdapter.vue'
import Checkbox from './PrimeCheckboxAdapter.vue'
import DateField from './PrimeDateFieldAdapter.vue'
import NumberField from './PrimeNumberFieldAdapter.vue'
import Dialog from './PrimeDialogAdapter.vue'
import StatusTag from './PrimeStatusTagAdapter.vue'
import InlineMessage from './PrimeInlineMessageAdapter.vue'
import Paginator from './PrimePaginatorAdapter.vue'
import Tabs from './PrimeTabsAdapter.vue'
import DataGrid from './AgGridAdapter.vue'
const lazy = (loader: () => Promise<{ default: Component }>) => defineAsyncComponent({ loader, suspensible: false })
const Button = lazy(() => import('./PrimeButtonAdapter.vue'))
const TextField = lazy(() => import('./PrimeTextFieldAdapter.vue'))
const TextArea = lazy(() => import('./PrimeTextAreaAdapter.vue'))
const Select = lazy(() => import('./PrimeSelectAdapter.vue'))
const MultiSelect = lazy(() => import('./PrimeMultiSelectAdapter.vue'))
const Checkbox = lazy(() => import('./PrimeCheckboxAdapter.vue'))
const DateField = lazy(() => import('./PrimeDateFieldAdapter.vue'))
const NumberField = lazy(() => import('./PrimeNumberFieldAdapter.vue'))
const Dialog = lazy(() => import('./PrimeDialogAdapter.vue'))
const StatusTag = lazy(() => import('./PrimeStatusTagAdapter.vue'))
const InlineMessage = lazy(() => import('./PrimeInlineMessageAdapter.vue'))
const Paginator = lazy(() => import('./PrimePaginatorAdapter.vue'))
const Tabs = lazy(() => import('./PrimeTabsAdapter.vue'))
const DataGrid = lazy(() => import('./AgGridAdapter.vue'))
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
@@ -0,0 +1,21 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
function sourceFiles(root: string): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap(entry => {
const path = join(root, entry.name)
if (entry.isDirectory()) return sourceFiles(path)
return /\.(ts|vue)$/.test(entry.name) ? [path] : []
})
}
describe('UI vendor boundary', () => {
it('keeps PrimeVue and AG Grid imports inside the shared adapter', () => {
const featureRoot = join(process.cwd(), 'src', 'features')
const forbiddenImport = /(?:from|import\s*\()\s*["'](?:primevue|ag-grid)/
const violations = sourceFiles(featureRoot).filter(path => forbiddenImport.test(readFileSync(path, 'utf8')))
expect(violations).toEqual([])
})
})
@@ -0,0 +1,62 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import KsButton from '../KsButton.vue'
import KsTextField from '../KsTextField.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const primeVueHarness = {
stubs: {
Button: {
props: ['label', 'type', 'loading', 'disabled'],
template: '<button :type="type" :disabled="disabled || loading" @click="$emit(\'click\', $event)">{{ label }}<span v-if="loading">…</span><slot /></button>',
},
InputText: {
props: ['modelValue', 'inputId', 'type', 'disabled', 'invalid', 'placeholder'],
template: '<input :id="inputId" :value="modelValue" :type="type" :disabled="disabled" :aria-invalid="invalid ? \'true\' : undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
},
}
describe('core shared control contracts', () => {
it('keeps a loading button disabled and forwards its semantic type', () => {
const wrapper = mount(KsButton, {
props: { label: 'Save', type: 'submit', loading: true },
global: primeVueHarness,
})
const button = wrapper.get('button')
expect(button.attributes()).toMatchObject({ type: 'submit', disabled: '' })
expect(button.text()).toContain('…')
})
it('forwards activation through the vendor-neutral click event', async () => {
const wrapper = mount(KsButton, {
props: { label: 'Retry' },
global: primeVueHarness,
})
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('connects text-field label, invalid state, and model updates', async () => {
const wrapper = mount(KsTextField, {
props: { modelValue: 'old', label: 'Name', inputId: 'name', error: 'Required', type: 'email' },
global: { ...primeVueHarness, provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } },
})
const input = wrapper.get('input')
expect(wrapper.get('label').attributes('for')).toBe('name')
expect(input.attributes()).toMatchObject({
id: 'name',
type: 'email',
'aria-describedby': 'name-message',
'aria-invalid': 'true',
})
expect(input.attributes('aria-required')).toBeUndefined()
await input.setValue('new@example.com')
expect(wrapper.emitted('update:modelValue')).toEqual([['new@example.com']])
})
})
@@ -0,0 +1,37 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import KsDataGrid from '../KsDataGrid.vue'
const gridHarness = {
stubs: {
AgGridVue: {
props: ['rowData', 'columnDefs', 'rowSelection', 'loading'],
template: '<button data-test="grid" @click="$emit(\'row-clicked\', { data: rowData[0] })">select</button>',
},
},
}
describe('KsDataGrid contract', () => {
it('preserves grid defaults and native grid configuration', () => {
const rows = [{ id: 'row-1' }]
const columns = [{ field: 'id', header: 'ID' }]
const wrapper = mount(KsDataGrid, {
props: { rows, columns },
global: gridHarness,
})
expect(wrapper.find('[data-test="grid"]').exists()).toBe(true)
expect(wrapper.html()).toContain('grid')
})
it('forwards the selected row without changing its identity', async () => {
const row = { id: 'row-1', status: 'READY' }
const wrapper = mount(KsDataGrid, {
props: { rows: [row], columns: [] },
global: gridHarness,
})
await wrapper.get('[data-test="grid"]').trigger('click')
expect(wrapper.emitted('rowSelected')).toEqual([[row]])
})
})
@@ -5,7 +5,17 @@ import { nativeUiAdapter } from '../../adapter/native'
import KsMoneyField from '../KsMoneyField.vue'
import KsQuantityField from '../KsQuantityField.vue'
const globalProvide = { global: { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } } }
const globalProvide = {
global: {
provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter },
stubs: {
InputNumber: {
props: ['modelValue', 'inputId'],
template: '<input :id="inputId" :value="modelValue" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
},
},
},
}
describe('KsMoneyField', () => {
it('connects the label, shows the currency code, and emits numeric updates', async () => {
@@ -0,0 +1,15 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import KsStatusTag from '../KsStatusTag.vue'
describe('KsStatusTag semantic and unknown contract', () => {
it('exposes text and non-colour cues for an unknown status', () => {
const wrapper = mount(KsStatusTag, {
props: { value: '상태 미등록 · VENDOR_NEW', semantic: 'warning', unknown: true },
})
const tag = wrapper.find('.ks-status-tag')
expect(tag.attributes('data-semantic')).toBe('warning')
expect(tag.attributes('aria-label')).toContain('정의되지 않음')
expect(tag.text()).toContain('?')
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
export type StandardUiState = 'LOADING' | 'EMPTY' | 'WARN' | 'ERROR' | 'EXPIRED' | 'UNAUTHORIZED' | 'READONLY' | 'DIRTY' | 'CONFLICT' | 'PROCESSING' | 'PARTIAL' | 'READY'
export type StandardUiState = 'LOADING' | 'EMPTY' | 'WARN' | 'ERROR' | 'EXPIRED' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'READONLY' | 'DIRTY' | 'CONFLICT' | 'PROCESSING' | 'PARTIAL' | 'READY'
withDefaults(defineProps<{ state: StandardUiState; title?: string; message?: string; traceId?: string; retryable?: boolean }>(), { state: 'READY', retryable: false })
defineEmits<{ retry: [] }>()
</script>
@@ -0,0 +1,27 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StandardStatePanel, { type StandardUiState } from '../StandardStatePanel.vue'
const states: readonly StandardUiState[] = [
'READY', 'LOADING', 'EMPTY', 'WARN', 'ERROR', 'EXPIRED', 'UNAUTHORIZED', 'FORBIDDEN',
'READONLY', 'DIRTY', 'CONFLICT', 'PROCESSING', 'PARTIAL'
]
describe('StandardStatePanel canonical state contract', () => {
it.each(states)('renders the %s state without inventing a fallback state', state => {
const wrapper = mount(StandardStatePanel, { props: { state } })
if (state === 'READY') expect(wrapper.find('[data-state]').exists()).toBe(false)
else expect(wrapper.find(`[data-state="${state}"]`).exists()).toBe(true)
})
it('does not expose retry for non-retryable states unless explicitly enabled', () => {
const wrapper = mount(StandardStatePanel, { props: { state: 'FORBIDDEN' } })
expect(wrapper.find('[data-state="FORBIDDEN"] button').exists()).toBe(false)
})
it('emits retry only when the caller explicitly enables it', async () => {
const wrapper = mount(StandardStatePanel, { props: { state: 'ERROR', retryable: true } })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,20 @@
import type { KbxGridColumn } from '@shared/contracts/kbx-types'
import type { UiGridColumn } from './adapter/contracts'
/** Converts registry-owned KBX columns into the provider-neutral grid contract. */
export function toUiGridColumns(columns: readonly KbxGridColumn[]): UiGridColumn[] {
return columns.map(column => {
if (typeof column.field !== 'string') {
throw new Error(`Grid column field must be a string: ${String(column.field)}`)
}
return {
field: column.field,
header: column.header,
width: typeof column.width === 'number' ? column.width : undefined,
sortable: column.sortable,
filterable: column.filterable,
formatter: column.formatter,
}
})
}
+47
View File
@@ -0,0 +1,47 @@
export type UiGridStatusSemantic =
| 'ready' | 'info' | 'pending' | 'processing' | 'completed'
| 'warning' | 'hold' | 'error' | 'cancelled' | 'disabled'
export interface UiGridStatusDefinition {
value: string
label: string
semantic: UiGridStatusSemantic
}
export interface UiGridStatusMap {
definitions: readonly UiGridStatusDefinition[]
unknownLabel?: string
}
export interface ResolvedUiGridStatus {
rawValue: string
label: string
semantic: UiGridStatusSemantic
unknown: boolean
}
/** Preserves the API/domain value and resolves only presentation metadata. */
export function resolveUiGridStatus(map: UiGridStatusMap | undefined, value: unknown): ResolvedUiGridStatus {
const rawValue = value == null ? '' : String(value)
const definition = map?.definitions.find(item => item.value === rawValue)
if (definition) return { rawValue, label: definition.label, semantic: definition.semantic, unknown: false }
return {
rawValue,
label: `${map?.unknownLabel ?? '정의되지 않음'} · ${rawValue || '빈 값'}`,
semantic: 'warning',
unknown: true,
}
}
/** Filters on the visible label while preserving the canonical row value. */
export function matchesUiGridStatus(map: UiGridStatusMap | undefined, value: unknown, query: string): boolean {
const normalizedQuery = query.trim().toLocaleLowerCase()
if (!normalizedQuery) return true
const resolved = resolveUiGridStatus(map, value)
return resolved.label.toLocaleLowerCase().includes(normalizedQuery)
}
/** Export status labels, but keep non-status values unchanged at the boundary. */
export function formatUiGridCellForExport(map: UiGridStatusMap | undefined, value: unknown): string {
return map ? resolveUiGridStatus(map, value).label : value == null ? '' : String(value)
}
@@ -0,0 +1,37 @@
/**
* KBX v60 T01 recipe adapted to the existing screen-type boundary.
* This is declarative metadata; it does not create client-side data ownership
* or execute commands on behalf of a screen.
*/
export const searchListRecipe = Object.freeze({
id: 'T01',
requiredPolicies: Object.freeze([
'server-read-model',
'tanstack-query',
'search-condition-preservation',
'server-side-bulk-selection',
]),
recoveryPolicies: Object.freeze([
'idle-before-first-search',
'retain-grid-during-refresh',
'retry-with-search-context',
'partial-bulk-result',
]),
securityPolicies: Object.freeze([
'screen-permission',
'command-permission',
'safe-drilldown-route',
'masked-sensitive-cells',
]),
})
export type SearchListRecipe = typeof searchListRecipe
export const workQueueRecipe = Object.freeze({
id: 'T12',
requiredPolicies: Object.freeze(['exception-first-projection', 'sla-state', 'server-side-bulk-selection', 'audit']),
recoveryPolicies: Object.freeze(['partial-action-result', 'retryable-vs-terminal-error', 'stale-event-suppression', 'detail-context-retention']),
securityPolicies: Object.freeze(['screen-permission', 'exception-action-permission', 'server-enforcement']),
})
export type WorkQueueRecipe = typeof workQueueRecipe
@@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ApprovalWorkbenchPage from '../v2/ApprovalWorkbenchPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('ApprovalWorkbenchPage T05 contract', () => {
it('renders queue/detail/decision slots and evidence version metadata', () => {
const wrapper = mount(ApprovalWorkbenchPage, {
global,
props: { title: '승인 검토', state: 'WARN', warning: '증거 확인 필요', evidence: { asOf: '2026-08-12T00:00:00Z', version: 'approval-v1' } },
slots: { queue: '<div data-test="queue">queue</div>', detail: '<div data-test="detail">detail</div>', decision: '<div data-test="decision">decision</div>' }
})
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: approval-v1')
expect(wrapper.find('[data-test="queue"]').exists()).toBe(true)
expect(wrapper.find('[data-test="detail"]').exists()).toBe(true)
expect(wrapper.find('[data-test="decision"]').exists()).toBe(true)
})
it('suppresses decision content in conflict state and forwards retry', async () => {
const wrapper = mount(ApprovalWorkbenchPage, {
global,
props: { title: '충돌 검토', state: 'CONFLICT' },
slots: { decision: '<div data-test="sensitive-decision">승인</div>' }
})
expect(wrapper.find('[data-test="sensitive-decision"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,30 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import BatchOperationsPageV2 from '../v2/BatchOperationsPageV2.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('BatchOperationsPageV2 T08 contract', () => {
it('forwards version metadata and renders runbook/timeline/records/reprocess slots', () => {
const wrapper = mount(BatchOperationsPageV2, {
global,
props: { title: '배치 운영', state: 'WARN', evidence: { version: 'job-v2' } },
slots: { runSummary: '<div data-test="summary">summary</div>', timeline: '<div data-test="timeline">timeline</div>', records: '<div data-test="records">records</div>', reprocess: '<div data-test="reprocess">reprocess</div>', runbook: '<div data-test="runbook">runbook</div>' }
})
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: job-v2')
expect(wrapper.find('[data-test="timeline"]').exists()).toBe(true)
expect(wrapper.find('[data-test="records"]').exists()).toBe(true)
expect(wrapper.find('[data-test="reprocess"]').exists()).toBe(true)
expect(wrapper.find('[data-test="runbook"]').exists()).toBe(true)
})
it('suppresses reprocess content while processing and forwards retry after error', async () => {
const wrapper = mount(BatchOperationsPageV2, { global, props: { title: '배치', state: 'PROCESSING' }, slots: { reprocess: '<div data-test="reprocess">재처리</div>' } })
expect(wrapper.find('[data-test="reprocess"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,37 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DetailReadPage from '../v2/DetailReadPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('DetailReadPage T02 contract', () => {
it('renders evidence as-of and version metadata with the detail slot', () => {
const wrapper = mount(DetailReadPage, {
global,
props: { title: '모델 상세', state: 'READY', evidence: { asOf: '2026-08-12T00:00:00Z', version: 'model-v2' } },
slots: { default: '<article data-test="detail">내용</article>', evidence: '<aside data-test="evidence">증거</aside>' }
})
expect(wrapper.find('.ks-page__meta').text()).toContain('As-of: 2026-08-12T00:00:00Z')
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: model-v2')
expect(wrapper.find('[data-test="detail"]').exists()).toBe(true)
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(true)
})
it('suppresses detail content while forbidden and forwards retry', async () => {
const wrapper = mount(DetailReadPage, {
global,
props: { title: '보호된 상세', state: 'FORBIDDEN' },
slots: { default: '<article data-test="sensitive-detail">민감 내용</article>' }
})
expect(wrapper.find('[data-test="sensitive-detail"]').exists()).toBe(false)
expect(wrapper.text()).toContain('권한 없음')
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,27 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import EditFormPage from '../v2/EditFormPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('EditFormPage T03 contract', () => {
it('prioritizes readonly over dirty and dirty over the supplied state', () => {
const wrapper = mount(EditFormPage, { global, props: { title: '편집', state: 'READY', dirty: true, readonly: true } })
expect(wrapper.find('.ks-page__meta').text()).toContain('상태: READONLY')
return wrapper.setProps({ readonly: false }).then(() => {
expect(wrapper.find('.ks-page__meta').text()).toContain('상태: DIRTY')
})
})
it('forwards submit and retry events through the shared boundaries', async () => {
const wrapper = mount(EditFormPage, { global, props: { title: '편집', state: 'READY' }, slots: { default: '<input />' } })
await wrapper.find('form').trigger('submit')
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('submit')).toHaveLength(1)
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import FastEntryGridPage from '../v2/FastEntryGridPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('FastEntryGridPage T11 contract', () => {
it('renders grid and validation summary slots with version metadata', () => {
const wrapper = mount(FastEntryGridPage, { global, props: { title: '대량 입력', state: 'DIRTY', evidence: { version: 'grid-v2' } }, slots: { grid: '<div data-test="grid">grid</div>', validationSummary: '<div data-test="validation">validation</div>' } })
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: grid-v2')
expect(wrapper.find('[data-test="grid"]').exists()).toBe(true)
expect(wrapper.find('[data-test="validation"]').exists()).toBe(true)
})
it('suppresses grid while processing and forwards retry after error', async () => {
const wrapper = mount(FastEntryGridPage, { global, props: { title: '대량 입력', state: 'PROCESSING' }, slots: { grid: '<div data-test="grid">grid</div>' } })
expect(wrapper.find('[data-test="grid"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MasterDetailCrudPage from '../v2/MasterDetailCrudPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('MasterDetailCrudPage T04 contract', () => {
it('forwards as-of/version metadata and renders master/detail slots', () => {
const wrapper = mount(MasterDetailCrudPage, {
global,
props: { title: '대사 상세', state: 'READY', evidence: { asOf: '2026-08-12T00:00:00Z', version: 'projection-v3' } },
slots: { master: '<section data-test="master">목록</section>', detail: '<aside data-test="detail">상세</aside>' }
})
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: projection-v3')
expect(wrapper.find('[data-test="master"]').exists()).toBe(true)
expect(wrapper.find('[data-test="detail"]').exists()).toBe(true)
})
it('does not expose detail content while unauthorized and forwards retry', async () => {
const wrapper = mount(MasterDetailCrudPage, {
global,
props: { title: '보호된 대사', state: 'FORBIDDEN' },
slots: { master: '<section>목록</section>', detail: '<aside data-test="sensitive-detail">민감 상세</aside>' }
})
expect(wrapper.find('[data-test="sensitive-detail"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,31 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ReconciliationExceptionPage from '../v2/ReconciliationExceptionPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('ReconciliationExceptionPage T09 contract', () => {
it('preserves version and comparison/correction/audit slots', () => {
const wrapper = mount(ReconciliationExceptionPage, {
global,
props: { title: '대사 예외', state: 'WARN', evidence: { version: 'recon-v2' } },
slots: { breaks: '<div data-test="breaks">breaks</div>', beforeAfter: '<div data-test="before-after">before-after</div>', correction: '<div data-test="correction">correction</div>', audit: '<div data-test="audit">audit</div>' }
})
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: recon-v2')
expect(wrapper.find('[data-test="breaks"]').exists()).toBe(true)
expect(wrapper.find('[data-test="before-after"]').exists()).toBe(true)
expect(wrapper.find('[data-test="correction"]').exists()).toBe(true)
expect(wrapper.find('[data-test="audit"]').exists()).toBe(true)
})
it('suppresses exception detail while forbidden and forwards retry after error', async () => {
const wrapper = mount(ReconciliationExceptionPage, { global, props: { title: '대사', state: 'FORBIDDEN' }, slots: { breaks: '<div data-test="breaks">민감한 break</div>', audit: '<div data-test="audit">감사</div>' } })
expect(wrapper.find('[data-test="breaks"]').exists()).toBe(false)
expect(wrapper.find('[data-test="audit"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,30 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ScorecardDashboardPage from '../v2/ScorecardDashboardPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('ScorecardDashboardPage T07 contract', () => {
it('renders dashboard slots and evidence version for a partial snapshot', () => {
const wrapper = mount(ScorecardDashboardPage, {
global,
props: { title: '리스크 Scorecard', state: 'PARTIAL', evidence: { asOf: '2026-08-12T00:00:00Z', version: 'scorecard-v2' } },
slots: { kpis: '<div data-test="kpis">KPI</div>', primary: '<div data-test="primary">Primary</div>', secondary: '<div data-test="secondary">Secondary</div>', alerts: '<div data-test="alerts">Alerts</div>', metricDefinitions: '<div data-test="metrics">Definitions</div>' }
})
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: scorecard-v2')
expect(wrapper.find('[data-test="kpis"]').exists()).toBe(true)
expect(wrapper.find('[data-test="metrics"]').exists()).toBe(true)
expect(wrapper.text()).toContain('일부 데이터만 표시')
})
it('suppresses dashboard content on forbidden and forwards retry after an error', async () => {
const wrapper = mount(ScorecardDashboardPage, { global, props: { title: '보호된 대시보드', state: 'FORBIDDEN' }, slots: { primary: '<div data-test="sensitive-kpi">민감 KPI</div>' } })
expect(wrapper.find('[data-test="sensitive-kpi"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -1,6 +1,10 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SearchListCrudPage from '../v2/SearchListCrudPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const adapterGlobal = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('SearchListCrudPage', () => {
it('keeps the T01 list body and optional detail in the shared responsive workspace', () => {
@@ -30,4 +34,23 @@ describe('SearchListCrudPage', () => {
expect(wrapper.find('.ks-crud-workspace__body').classes()).not.toContain('has-aside')
expect(wrapper.find('.ks-crud-workspace aside').exists()).toBe(false)
})
it('does not expose list content while the screen is forbidden', () => {
const wrapper = mount(SearchListCrudPage, {
props: { title: '권한 목록', state: 'FORBIDDEN' },
global: adapterGlobal,
slots: { default: '<section data-test="sensitive-list">민감 목록</section>' }
})
expect(wrapper.find('[data-test="sensitive-list"]').exists()).toBe(false)
expect(wrapper.text()).toContain('권한 없음')
})
it('forwards retry from the shared state boundary', async () => {
const wrapper = mount(SearchListCrudPage, { props: { title: '목록', state: 'ERROR' }, global: adapterGlobal })
const boundary = wrapper.findComponent({ name: 'QueryStateBoundary' })
await boundary.vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,32 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StandardScreenBoundary from '../v2/StandardScreenBoundary.vue'
describe('StandardScreenBoundary contract', () => {
it('maps screen states into the shared query boundary contract', () => {
const wrapper = mount(StandardScreenBoundary, {
props: { state: 'ERROR', error: new Error('failed'), correlationId: 'corr-1', staleAt: '2026-08-13T00:00:00Z' },
global: {
stubs: {
QueryStateBoundary: { name: 'QueryStateBoundary', template: '<div><slot /></div>', props: ['error', 'correlationId', 'staleAt', 'loading', 'processing'] },
},
},
slots: { default: '<article data-test="content">content</article>' },
})
const boundary = wrapper.findComponent({ name: 'QueryStateBoundary' })
expect(boundary.props()).toMatchObject({ loading: false, processing: false, correlationId: 'corr-1', staleAt: '2026-08-13T00:00:00Z' })
expect(boundary.props('error')).toBeInstanceOf(Error)
expect((boundary.props('error') as Error).message).toBe('failed')
})
it('forwards retry from the shared query boundary', async () => {
const wrapper = mount(StandardScreenBoundary, {
props: { state: 'READY' },
global: { stubs: { QueryStateBoundary: { name: 'QueryStateBoundary', template: '<div><slot /></div>' } } },
})
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,25 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StepWizardPage from '../v2/StepWizardPage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('StepWizardPage T06 contract', () => {
it('renders progress, evidence version, and forwards navigation events', async () => {
const wrapper = mount(StepWizardPage, { global, props: { title: '설정', state: 'READY', currentStep: 1, totalSteps: 2, evidence: { version: 'wizard-v1' } } })
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: wizard-v1')
expect(wrapper.get('progress').attributes('value')).toBe('1')
await wrapper.get('button').trigger('click')
await wrapper.findAll('button')[1].trigger('click')
expect(wrapper.emitted('next')).toHaveLength(1)
})
it('hides wizard actions while readonly/error/processing state blocks action', () => {
for (const state of ['READONLY', 'ERROR', 'PROCESSING'] as const) {
const wrapper = mount(StepWizardPage, { global, props: { title: '설정', state, currentStep: 1, totalSteps: 2 } })
expect(wrapper.find('.ks-page__footer').exists()).toBe(false)
}
})
})
@@ -0,0 +1,27 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import VersionGovernancePage from '../v2/VersionGovernancePage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('VersionGovernancePage T10 contract', () => {
it('renders version comparison, evidence, approval and rollback slots with version', () => {
const wrapper = mount(VersionGovernancePage, { global, props: { title: '버전 거버넌스', state: 'WARN', evidence: { version: 'model-v4' } }, slots: { versionComparison: '<div data-test="comparison">comparison</div>', evidenceMatrix: '<div data-test="evidence">evidence</div>', approval: '<div data-test="approval">approval</div>', rollback: '<div data-test="rollback">rollback</div>' } })
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: model-v4')
expect(wrapper.find('[data-test="comparison"]').exists()).toBe(true)
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(true)
expect(wrapper.find('[data-test="approval"]').exists()).toBe(true)
expect(wrapper.find('[data-test="rollback"]').exists()).toBe(true)
})
it('suppresses governance actions while readonly and forwards retry after error', async () => {
const wrapper = mount(VersionGovernancePage, { global, props: { title: '거버넌스', state: 'READONLY' }, slots: { approval: '<div data-test="approval">승인</div>', rollback: '<div data-test="rollback">롤백</div>' } })
expect(wrapper.find('[data-test="approval"]').exists()).toBe(false)
expect(wrapper.find('[data-test="rollback"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import WorkQueuePage from '../v2/WorkQueuePage.vue'
import { nativeUiAdapter } from '../../adapter/native'
import { uiAdapterKey } from '../../adapter/contracts'
const global = { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } }
describe('WorkQueuePage T12 contract', () => {
it('renders queue and exception summary with version metadata', () => {
const wrapper = mount(WorkQueuePage, { global, props: { title: '작업 큐', state: 'WARN', evidence: { version: 'queue-v2' } }, slots: { exceptionSummary: '<div data-test="summary">summary</div>', queue: '<div data-test="queue">queue</div>' } })
expect(wrapper.find('.ks-page__meta').text()).toContain('Version: queue-v2')
expect(wrapper.find('[data-test="summary"]').exists()).toBe(true)
expect(wrapper.find('[data-test="queue"]').exists()).toBe(true)
})
it('suppresses queue content while processing and forwards retry after error', async () => {
const wrapper = mount(WorkQueuePage, { global, props: { title: '작업 큐', state: 'PROCESSING' }, slots: { queue: '<div data-test="queue">queue</div>' } })
expect(wrapper.find('[data-test="queue"]').exists()).toBe(false)
await wrapper.setProps({ state: 'ERROR' })
await wrapper.findComponent({ name: 'QueryStateBoundary' }).vm.$emit('retry')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { searchListRecipe } from '../screenRecipe'
describe('T01 screen recipe adoption', () => {
it('preserves the KBX recovery and security policies as immutable metadata', () => {
expect(searchListRecipe.id).toBe('T01')
expect(searchListRecipe.recoveryPolicies).toEqual([
'idle-before-first-search',
'retain-grid-during-refresh',
'retry-with-search-context',
'partial-bulk-result',
])
expect(searchListRecipe.securityPolicies).toContain('masked-sensitive-cells')
expect(Object.isFrozen(searchListRecipe)).toBe(true)
})
})
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { workQueueRecipe } from '../screenRecipe'
describe('T12 screen recipe adoption', () => {
it('preserves queue recovery and security policies as immutable metadata', () => {
expect(workQueueRecipe.id).toBe('T12')
expect(workQueueRecipe.requiredPolicies).toContain('exception-first-projection')
expect(workQueueRecipe.recoveryPolicies).toContain('retryable-vs-terminal-error')
expect(workQueueRecipe.securityPolicies).toEqual(['screen-permission', 'exception-action-permission', 'server-enforcement'])
expect(Object.isFrozen(workQueueRecipe)).toBe(true)
})
})
@@ -1,2 +1,2 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import ReviewWorkbenchLayout from '../../layouts/ReviewWorkbenchLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><ReviewWorkbenchLayout><template #queue><slot name="queue"/></template><template #detail><slot name="detail"/></template><template #decision><slot name="decision"/></template></ReviewWorkbenchLayout></StandardScreenBoundary></PageLayout></template>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><ReviewWorkbenchLayout><template #queue><slot name="queue"/></template><template #detail><slot name="detail"/></template><template #decision><slot name="decision"/></template></ReviewWorkbenchLayout></StandardScreenBoundary></PageLayout></template>
@@ -1,3 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><template #summary><slot name="runSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="timeline"/></div><div class="ks-card ks-section"><slot name="records"/></div><div class="ks-card ks-section"><slot name="reprocess"/></div></section></StandardScreenBoundary><template #aside><slot name="runbook"/></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const contentBlocked = computed(() => ['LOADING','ERROR','UNAUTHORIZED','FORBIDDEN','CONFLICT','EXPIRED','READONLY','PROCESSING'].includes(props.state ?? 'READY')); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><template #summary><slot name="runSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><section v-if="!contentBlocked" class="ks-stack"><div class="ks-card ks-section"><slot name="timeline"/></div><div class="ks-card ks-section"><slot name="records"/></div><div class="ks-card ks-section"><slot name="reprocess"/></div></section></StandardScreenBoundary><template #aside><slot name="runbook"/></template></PageLayout></template>
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
@@ -1,2 +1,2 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import FormPageLayout from '../../layouts/FormPageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{submit:[];retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><FormPageLayout @submit="$emit('submit')"><slot/><template v-if="$slots.preview" #preview><slot name="preview"/></template></FormPageLayout></StandardScreenBoundary><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import FormPageLayout from '../../layouts/FormPageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=withDefaults(defineProps<StandardScreenProps & { dirty?: boolean; readonly?: boolean }>(), { dirty: false, readonly: false }); const effectiveState = computed(() => props.readonly ? 'READONLY' : props.dirty ? 'DIRTY' : props.state); defineEmits<{submit:[];retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="effectiveState" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="effectiveState" :warning="props.warning" @retry="$emit('retry')"><FormPageLayout @submit="$emit('submit')"><slot/><template v-if="$slots.preview" #preview><slot name="preview"/></template></FormPageLayout></StandardScreenBoundary><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
@@ -1,3 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="grid"/></div><div v-if="$slots.validationSummary" class="ks-card ks-section"><slot name="validationSummary"/></div></section></StandardScreenBoundary><template #summary><slot name="total"/></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const gridContentBlocked = computed(() => ['LOADING','ERROR','UNAUTHORIZED','FORBIDDEN','CONFLICT','EXPIRED','READONLY','PROCESSING'].includes(props.state ?? 'READY')); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section v-if="!gridContentBlocked" class="ks-stack"><div class="ks-card ks-section"><slot name="grid"/></div><div v-if="$slots.validationSummary" class="ks-card ks-section"><slot name="validationSummary"/></div></section></StandardScreenBoundary><template #summary><slot name="total"/></template></PageLayout></template>
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
@@ -1,2 +1,2 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" aside-width="28rem"><template #actions><slot name="actions"/></template><template #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot name="master"/></StandardScreenBoundary><template #aside><slot name="detail"/></template><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const hideDetail = computed(() => props.state === 'UNAUTHORIZED' || props.state === 'FORBIDDEN'); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version" aside-width="28rem"><template #actions><slot name="actions"/></template><template #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot name="master"/></StandardScreenBoundary><template #aside><slot v-if="!hideDetail" name="detail"/></template><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
@@ -1,3 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" aside-width="30rem"><template #actions><slot name="actions"/></template><template #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot name="breaks"/></StandardScreenBoundary><template #aside><section class="ks-stack"><div class="ks-card ks-section"><slot name="beforeAfter"/></div><div class="ks-card ks-section"><slot name="correction"/></div><div class="ks-card ks-section"><slot name="audit"/></div></section></template><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const sensitiveContentBlocked = computed(() => ['LOADING','ERROR','UNAUTHORIZED','FORBIDDEN','CONFLICT','EXPIRED','READONLY','PROCESSING'].includes(props.state ?? 'READY')); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version" aside-width="30rem"><template #actions><slot name="actions"/></template><template #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot v-if="!sensitiveContentBlocked" name="breaks"/></StandardScreenBoundary><template #aside><section v-if="!sensitiveContentBlocked" class="ks-stack"><div class="ks-card ks-section"><slot name="beforeAfter"/></div><div class="ks-card ks-section"><slot name="correction"/></div><div class="ks-card ks-section"><slot name="audit"/></div></section></template><template v-if="$slots.footer && !sensitiveContentBlocked" #footer><slot name="footer"/></template></PageLayout></template>
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
@@ -1,2 +1,2 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import DashboardLayout from '../../layouts/DashboardLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="actions"/></template><template #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><DashboardLayout><template #kpis><slot name="kpis"/></template><template #primary><slot name="primary"/></template><template #secondary><slot name="secondary"/></template><template #alerts><slot name="alerts"/></template></DashboardLayout></StandardScreenBoundary><template v-if="$slots.metricDefinitions" #aside><slot name="metricDefinitions"/></template></PageLayout></template>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><template #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><DashboardLayout><template #kpis><slot name="kpis"/></template><template #primary><slot name="primary"/></template><template #secondary><slot name="secondary"/></template><template #alerts><slot name="alerts"/></template></DashboardLayout></StandardScreenBoundary><template v-if="$slots.metricDefinitions" #aside><slot name="metricDefinitions"/></template></PageLayout></template>
@@ -1,3 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps & {currentStep:number;totalSteps:number}>(); defineEmits<{previous:[];next:[];finish:[];retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state"><template #summary><div class="ks-card ks-wizard-progress" role="status">단계 {{ currentStep }} / {{ totalSteps }}<progress :value="currentStep" :max="totalSteps"/></div></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot/></StandardScreenBoundary><template #footer><slot name="footer"><button type="button" :disabled="currentStep<=1" @click="$emit('previous')">이전</button><button v-if="currentStep<totalSteps" type="button" @click="$emit('next')">다음</button><button v-else type="button" @click="$emit('finish')">완료</button></slot></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps & {currentStep:number;totalSteps:number}>(); const actionsBlocked = computed(() => ['LOADING','ERROR','UNAUTHORIZED','FORBIDDEN','CONFLICT','EXPIRED','READONLY','PROCESSING'].includes(props.state ?? 'READY')); defineEmits<{previous:[];next:[];finish:[];retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #summary><div class="ks-card ks-wizard-progress" role="status">단계 {{ currentStep }} / {{ totalSteps }}<progress :value="currentStep" :max="totalSteps"/></div></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot/></StandardScreenBoundary><template v-if="!actionsBlocked" #footer><slot name="footer"><button type="button" :disabled="currentStep<=1" @click="$emit('previous')">이전</button><button v-if="currentStep<totalSteps" type="button" @click="$emit('next')">다음</button><button v-else type="button" @click="$emit('finish')">완료</button></slot></template></PageLayout></template>
<style scoped>.ks-wizard-progress{display:grid;gap:var(--ks-space-2);padding:var(--ks-space-3)}progress{width:100%}</style>
@@ -1,3 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version" aside-width="28rem"><template #actions><slot name="actions"/></template><template #summary><slot name="gateSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="versionComparison"/></div><div class="ks-card ks-section"><slot name="evidenceMatrix"/></div></section></StandardScreenBoundary><template #aside><section class="ks-stack"><div class="ks-card ks-section"><slot name="approval"/></div><div class="ks-card ks-section"><slot name="rollback"/></div></section></template><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const governanceBlocked = computed(() => ['LOADING','ERROR','UNAUTHORIZED','FORBIDDEN','CONFLICT','EXPIRED','READONLY','PROCESSING'].includes(props.state ?? 'READY')); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version" aside-width="28rem"><template #actions><slot name="actions"/></template><template #summary><slot name="gateSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><section v-if="!governanceBlocked" class="ks-stack"><div class="ks-card ks-section"><slot name="versionComparison"/></div><div class="ks-card ks-section"><slot name="evidenceMatrix"/></div></section></StandardScreenBoundary><template #aside><section v-if="!governanceBlocked" class="ks-stack"><div class="ks-card ks-section"><slot name="approval"/></div><div class="ks-card ks-section"><slot name="rollback"/></div></section></template><template v-if="$slots.footer && !governanceBlocked" #footer><slot name="footer"/></template></PageLayout></template>
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
@@ -1,3 +1,3 @@
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="quickActions"/></template><template #summary><slot name="workSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section class="ks-stack"><div v-if="$slots.exceptionSummary" class="ks-card ks-section"><slot name="exceptionSummary"/></div><div class="ks-card ks-section"><slot name="queue"/></div></section></StandardScreenBoundary></PageLayout></template>
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const queueContentBlocked = computed(() => ['LOADING','ERROR','UNAUTHORIZED','FORBIDDEN','CONFLICT','EXPIRED','READONLY','PROCESSING'].includes(props.state ?? 'READY')); defineEmits<{retry:[]}>()</script>
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="quickActions"/></template><template #summary><slot name="workSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section v-if="!queueContentBlocked" class="ks-stack"><div v-if="$slots.exceptionSummary" class="ks-card ks-section"><slot name="exceptionSummary"/></div><div class="ks-card ks-section"><slot name="queue"/></div></section></StandardScreenBoundary></PageLayout></template>
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
@@ -0,0 +1,27 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DataGridShell from '../DataGridShell.vue'
import { nativeUiAdapter } from '../adapter/native'
import { uiAdapterKey } from '../adapter/contracts'
const global = {
provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter },
stubs: { Paginator: { template: '<div data-test="paginator" />' } },
}
describe('DataGridShell server-side pagination contract', () => {
it('does not invent pagination when server page metadata is absent', () => {
const wrapper = mount(DataGridShell, { global, props: { rows: [{ id: 1 }], columns: [{ field: 'id', header: 'ID' }] } })
expect(wrapper.findComponent({ name: 'KsPaginator' }).exists()).toBe(false)
})
it('forwards explicit page changes without owning the data fetch', async () => {
const wrapper = mount(DataGridShell, {
global,
props: { rows: [{ id: 1 }], columns: [{ field: 'id', header: 'ID' }], page: 1, pageSize: 20, total: 40 }
})
const paginator = wrapper.findComponent({ name: 'KsPaginator' })
await paginator.vm.$emit('pageChange', { page: 2, pageSize: 20 })
expect(wrapper.emitted('pageChange')).toEqual([[{ page: 2, pageSize: 20 }]])
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { toUiGridColumns } from '../gridColumnAdapter'
describe('toUiGridColumns', () => {
it('preserves the provider-neutral column semantics and formatter', () => {
const formatter = (value: unknown) => String(value ?? '')
expect(toUiGridColumns([{
field: 'modelId',
header: 'Model ID',
width: 150,
sortable: false,
filterable: true,
formatter,
}])).toEqual([{
field: 'modelId',
header: 'Model ID',
width: 150,
sortable: false,
filterable: true,
formatter,
}])
})
it('does not guess how string widths should be interpreted', () => {
expect(toUiGridColumns([{ field: 'name', header: 'Name', width: '20rem' }])).toEqual([{
field: 'name',
header: 'Name',
width: undefined,
sortable: undefined,
filterable: undefined,
formatter: undefined,
}])
})
it('rejects non-string fields before they reach an adapter', () => {
expect(() => toUiGridColumns([{ field: 1, header: 'Invalid' }])).toThrow('Grid column field must be a string')
})
})
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { formatUiGridCellForExport, matchesUiGridStatus, resolveUiGridStatus } from '../gridStatus'
const map = {
definitions: [{ value: 'READY', label: '대기', semantic: 'ready' as const }],
unknownLabel: '상태 미등록',
}
describe('KBX-derived grid status boundary', () => {
it('keeps raw canonical values while resolving display metadata', () => {
expect(resolveUiGridStatus(map, 'READY')).toEqual({ rawValue: 'READY', label: '대기', semantic: 'ready', unknown: false })
})
it('makes unknown values visible as warnings', () => {
expect(resolveUiGridStatus(map, 'NEW_VENDOR_STATE')).toEqual({
rawValue: 'NEW_VENDOR_STATE', label: '상태 미등록 · NEW_VENDOR_STATE', semantic: 'warning', unknown: true,
})
})
it('filters by the display label without changing the canonical value', () => {
expect(matchesUiGridStatus(map, 'READY', '대기')).toBe(true)
expect(matchesUiGridStatus(map, 'READY', 'READY')).toBe(false)
})
it('exports the display label only when a status map is supplied', () => {
expect(formatUiGridCellForExport(map, 'READY')).toBe('대기')
expect(formatUiGridCellForExport(undefined, 'READY')).toBe('READY')
})
})