393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
/**
|
||
* GenUI Registry 單元測試
|
||
* =======================
|
||
* Phase 19.6: Zod Schema 驗證 + Registry API 測試
|
||
*
|
||
* 測試內容:
|
||
* 1. Zod Schema 驗證 — 每個組件的合法/非法 Props
|
||
* 2. Registry API — getComponent, isRegistered, getRegisteredComponents
|
||
* 3. getTerminalComponents — 只返回 allowInTerminal=true 的組件
|
||
* 4. validateProps — 錯誤碼分類 (UNKNOWN_COMPONENT / ZOD_VALIDATION_FAILED)
|
||
*
|
||
* 注意:registry.ts 使用 React.lazy,在 Node 環境需以測試替身隔離 lazy 載入
|
||
*
|
||
* @see ADR-032 GenUI Dynamic Rendering
|
||
* Phase 19.6 ogt 2026-03-31 (台北時間)
|
||
*/
|
||
|
||
import { vi, describe, it, expect, beforeAll } from 'vitest'
|
||
|
||
// ===== React lazy 測試替身(Node 環境無法執行 lazy import) =====
|
||
vi.mock('react', () => ({
|
||
lazy: vi.fn((factory: () => Promise<unknown>) => factory),
|
||
}))
|
||
|
||
// ===== Import 測試目標 =====
|
||
import {
|
||
GENUI_REGISTRY,
|
||
ApprovalCardSchema,
|
||
MetricsSummaryCardSchema,
|
||
SentryErrorCardSchema,
|
||
IncidentTimelineCardSchema,
|
||
K8sPodStatusCardSchema,
|
||
TraceWaterfallCardSchema,
|
||
NuclearKeyButtonSchema,
|
||
getComponent,
|
||
isRegistered,
|
||
getRegisteredComponents,
|
||
getTerminalComponents,
|
||
validateProps,
|
||
} from '../registry'
|
||
|
||
// =============================================================================
|
||
// Registry 基礎 API
|
||
// =============================================================================
|
||
|
||
describe('Registry 基礎 API', () => {
|
||
it('應有 7 個已註冊組件', () => {
|
||
const names = getRegisteredComponents()
|
||
expect(names).toHaveLength(7)
|
||
})
|
||
|
||
it('getRegisteredComponents 包含所有預期組件', () => {
|
||
const names = getRegisteredComponents()
|
||
const expected = [
|
||
'ApprovalCard',
|
||
'MetricsSummaryCard',
|
||
'SentryErrorCard',
|
||
'IncidentTimelineCard',
|
||
'K8sPodStatusCard',
|
||
'TraceWaterfallCard',
|
||
'NuclearKeyButton',
|
||
]
|
||
expected.forEach(name => expect(names).toContain(name))
|
||
})
|
||
|
||
it('getComponent 已知組件返回定義', () => {
|
||
const def = getComponent('ApprovalCard')
|
||
expect(def).toBeDefined()
|
||
expect(def!.name).toBe('ApprovalCard')
|
||
expect(def!.allowInTerminal).toBe(true)
|
||
})
|
||
|
||
it('getComponent 未知組件返回 undefined', () => {
|
||
expect(getComponent('NonExistentComponent')).toBeUndefined()
|
||
})
|
||
|
||
it('isRegistered 已知組件返回 true', () => {
|
||
expect(isRegistered('ApprovalCard')).toBe(true)
|
||
expect(isRegistered('NuclearKeyButton')).toBe(true)
|
||
})
|
||
|
||
it('isRegistered 未知組件返回 false', () => {
|
||
expect(isRegistered('FakeCard')).toBe(false)
|
||
expect(isRegistered('')).toBe(false)
|
||
})
|
||
|
||
it('所有組件都設定 allowInTerminal=true', () => {
|
||
const components = getTerminalComponents()
|
||
expect(components).toHaveLength(7)
|
||
components.forEach(c => expect(c.allowInTerminal).toBe(true))
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// validateProps — 錯誤碼分類
|
||
// =============================================================================
|
||
|
||
describe('validateProps 錯誤碼分類', () => {
|
||
it('未知組件返回 UNKNOWN_COMPONENT 錯誤碼', () => {
|
||
const result = validateProps('UnknownCard', { foo: 'bar' })
|
||
expect(result.valid).toBe(false)
|
||
expect(result.errorCode).toBe('UNKNOWN_COMPONENT')
|
||
expect(result.errors).toHaveLength(1)
|
||
})
|
||
|
||
it('合法 Props 返回 valid=true,無錯誤', () => {
|
||
const result = validateProps('ApprovalCard', {
|
||
approvalId: 'APR-001',
|
||
riskLevel: 'critical',
|
||
kubectl: 'kubectl delete pod foo',
|
||
})
|
||
expect(result.valid).toBe(true)
|
||
expect(result.errors).toHaveLength(0)
|
||
expect(result.errorCode).toBeUndefined()
|
||
})
|
||
|
||
it('非法 Props 返回 ZOD_VALIDATION_FAILED 錯誤碼', () => {
|
||
const result = validateProps('ApprovalCard', {
|
||
approvalId: '', // min_length=1 違規
|
||
riskLevel: 'extreme', // 不在 enum 中
|
||
})
|
||
expect(result.valid).toBe(false)
|
||
expect(result.errorCode).toBe('ZOD_VALIDATION_FAILED')
|
||
expect(result.errors.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('NuclearKeyButton 缺少必填 label 應失敗', () => {
|
||
const result = validateProps('NuclearKeyButton', {
|
||
riskLevel: 'high',
|
||
// label 缺失
|
||
})
|
||
expect(result.valid).toBe(false)
|
||
expect(result.errorCode).toBe('ZOD_VALIDATION_FAILED')
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// ApprovalCardSchema
|
||
// =============================================================================
|
||
|
||
describe('ApprovalCardSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = ApprovalCardSchema.safeParse({
|
||
approvalId: 'APR-2026-0001',
|
||
riskLevel: 'critical',
|
||
kubectl: 'kubectl drain node-01',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('kubectl 為選填欄位', () => {
|
||
const result = ApprovalCardSchema.safeParse({
|
||
approvalId: 'APR-001',
|
||
riskLevel: 'low',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('空 approvalId 不通過 (min_length=1)', () => {
|
||
const result = ApprovalCardSchema.safeParse({
|
||
approvalId: '',
|
||
riskLevel: 'medium',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
|
||
it('非法 riskLevel 不通過', () => {
|
||
const result = ApprovalCardSchema.safeParse({
|
||
approvalId: 'APR-001',
|
||
riskLevel: 'extreme', // 不在 enum 中
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
|
||
it('所有合法 riskLevel 通過', () => {
|
||
const levels = ['low', 'medium', 'high', 'critical'] as const
|
||
levels.forEach(level => {
|
||
const result = ApprovalCardSchema.safeParse({ approvalId: 'x', riskLevel: level })
|
||
expect(result.success).toBe(true)
|
||
})
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// MetricsSummaryCardSchema
|
||
// =============================================================================
|
||
|
||
describe('MetricsSummaryCardSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = MetricsSummaryCardSchema.safeParse({
|
||
rps: 150.5,
|
||
errorRate: '0.05%',
|
||
p99Latency: '450ms',
|
||
status: 'healthy',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('errorRate 格式必須為百分比 (e.g. "0.05%")', () => {
|
||
const invalidFormats = ['0.05', '5 percent', 'high']
|
||
invalidFormats.forEach(fmt => {
|
||
const result = MetricsSummaryCardSchema.safeParse({
|
||
rps: 100, errorRate: fmt, p99Latency: '100ms', status: 'healthy',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
it('p99Latency 格式必須為時間 (e.g. "450ms" 或 "1.5s")', () => {
|
||
const invalidFormats = ['450', '1.5 seconds', 'fast']
|
||
invalidFormats.forEach(fmt => {
|
||
const result = MetricsSummaryCardSchema.safeParse({
|
||
rps: 100, errorRate: '1%', p99Latency: fmt, status: 'healthy',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
it('合法 p99Latency 格式: ms 與 s', () => {
|
||
const validFormats = ['100ms', '1.5s', '0.5s', '1000ms']
|
||
validFormats.forEach(fmt => {
|
||
const result = MetricsSummaryCardSchema.safeParse({
|
||
rps: 100, errorRate: '1%', p99Latency: fmt, status: 'warning',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
})
|
||
|
||
it('rps 不可為負數', () => {
|
||
const result = MetricsSummaryCardSchema.safeParse({
|
||
rps: -1, errorRate: '0%', p99Latency: '100ms', status: 'healthy',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// SentryErrorCardSchema
|
||
// =============================================================================
|
||
|
||
describe('SentryErrorCardSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = SentryErrorCardSchema.safeParse({
|
||
errorId: 'EVT-123',
|
||
title: 'TypeError: Cannot read property',
|
||
count: 42,
|
||
lastSeen: '2026-03-31T10:00:00Z',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('count 不可為負數 (min=0)', () => {
|
||
const result = SentryErrorCardSchema.safeParse({
|
||
errorId: 'x', title: 'Error', count: -1, lastSeen: '2026-01-01',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
|
||
it('count 必須為整數', () => {
|
||
const result = SentryErrorCardSchema.safeParse({
|
||
errorId: 'x', title: 'Error', count: 1.5, lastSeen: '2026-01-01',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// IncidentTimelineCardSchema
|
||
// =============================================================================
|
||
|
||
describe('IncidentTimelineCardSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = IncidentTimelineCardSchema.safeParse({
|
||
incidentId: 'INC-2026-0001',
|
||
events: [
|
||
{ timestamp: '2026-03-31T10:00:00Z', message: 'Alert fired', type: 'alert' },
|
||
{ timestamp: '2026-03-31T10:05:00Z', message: 'Acknowledged' },
|
||
],
|
||
status: 'active',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('空 events 陣列通過驗證', () => {
|
||
const result = IncidentTimelineCardSchema.safeParse({
|
||
incidentId: 'INC-001',
|
||
events: [],
|
||
status: 'resolved',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('非法 status 不通過', () => {
|
||
const result = IncidentTimelineCardSchema.safeParse({
|
||
incidentId: 'INC-001',
|
||
events: [],
|
||
status: 'escalated', // 不在 enum 中
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// K8sPodStatusCardSchema
|
||
// =============================================================================
|
||
|
||
describe('K8sPodStatusCardSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = K8sPodStatusCardSchema.safeParse({
|
||
namespace: 'harbor',
|
||
pods: [
|
||
{ name: 'harbor-core-xxx', status: 'Running', ready: true },
|
||
{ name: 'harbor-db-xxx', status: 'Pending' },
|
||
],
|
||
summary: { total: 5, running: 4, failed: 1 },
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('summary.total 不可為負數', () => {
|
||
const result = K8sPodStatusCardSchema.safeParse({
|
||
namespace: 'ns',
|
||
pods: [],
|
||
summary: { total: -1, running: 0 },
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// TraceWaterfallCardSchema
|
||
// =============================================================================
|
||
|
||
describe('TraceWaterfallCardSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = TraceWaterfallCardSchema.safeParse({
|
||
traceId: 'trace-abc123',
|
||
spans: [
|
||
{ spanId: 'span-1', name: 'HTTP GET /api/v1/incidents', duration: 150, startTime: 0 },
|
||
{ spanId: 'span-2', name: 'DB query', duration: 50 },
|
||
],
|
||
duration: 200,
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('duration 不可為負數', () => {
|
||
const result = TraceWaterfallCardSchema.safeParse({
|
||
traceId: 'x', spans: [], duration: -1,
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// NuclearKeyButtonSchema
|
||
// =============================================================================
|
||
|
||
describe('NuclearKeyButtonSchema', () => {
|
||
it('合法 Props 通過驗證', () => {
|
||
const result = NuclearKeyButtonSchema.safeParse({
|
||
label: '確認執行 kubectl drain',
|
||
riskLevel: 'critical',
|
||
approvalId: 'APR-001',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('approvalId 為選填欄位', () => {
|
||
const result = NuclearKeyButtonSchema.safeParse({
|
||
label: '確認',
|
||
riskLevel: 'high',
|
||
})
|
||
expect(result.success).toBe(true)
|
||
})
|
||
|
||
it('空 label 不通過 (min_length=1)', () => {
|
||
const result = NuclearKeyButtonSchema.safeParse({
|
||
label: '',
|
||
riskLevel: 'low',
|
||
})
|
||
expect(result.success).toBe(false)
|
||
})
|
||
|
||
it('NuclearKeyButton riskLevel 支援 high 與 critical', () => {
|
||
const levels = ['low', 'medium', 'high', 'critical'] as const
|
||
levels.forEach(level => {
|
||
const result = NuclearKeyButtonSchema.safeParse({ label: 'test', riskLevel: level })
|
||
expect(result.success).toBe(true)
|
||
})
|
||
})
|
||
})
|