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
+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')
})
})