Files
awoooi/apps/web/src/components/genui/__tests__/registry.test.ts
Your Name d5ce17c72d
All checks were successful
CD Pipeline / tests (push) Successful in 1m23s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 3m41s
CD Pipeline / post-deploy-checks (push) Successful in 2m7s
fix(web): 標示 AIOps 範例資料模式
2026-06-05 01:28:59 +08:00

393 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
})
})
})