Files
taskgraph_ts/test/schema.test.ts
2026-04-27 11:11:07 +00:00

876 lines
30 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { Value } from '@alkdev/typebox/value';
import {
TaskScopeEnum,
TaskRiskEnum,
TaskImpactEnum,
TaskLevelEnum,
TaskPriorityEnum,
TaskStatusEnum,
Nullable,
} from '../src/schema/enums.js';
import {
TaskGraphNodeAttributes,
TaskGraphNodeAttributesUpdate,
TaskGraphEdgeAttributes,
TaskGraphSerialized,
SerializedGraph,
} from '../src/schema/graph.js';
describe('Enum schemas', () => {
// --- TaskScopeEnum ---
describe('TaskScopeEnum', () => {
const validValues = ['single', 'narrow', 'moderate', 'broad', 'system'] as const;
it('accepts each valid literal', () => {
for (const value of validValues) {
expect(Value.Check(TaskScopeEnum, value)).toBe(true);
}
});
it('rejects invalid values', () => {
expect(Value.Check(TaskScopeEnum, 'unknown')).toBe(false);
expect(Value.Check(TaskScopeEnum, '')).toBe(false);
expect(Value.Check(TaskScopeEnum, 42)).toBe(false);
expect(Value.Check(TaskScopeEnum, null)).toBe(false);
});
});
// --- TaskRiskEnum ---
describe('TaskRiskEnum', () => {
const validValues = ['trivial', 'low', 'medium', 'high', 'critical'] as const;
it('accepts each valid literal', () => {
for (const value of validValues) {
expect(Value.Check(TaskRiskEnum, value)).toBe(true);
}
});
it('rejects invalid values', () => {
expect(Value.Check(TaskRiskEnum, 'unknown')).toBe(false);
expect(Value.Check(TaskRiskEnum, '')).toBe(false);
expect(Value.Check(TaskRiskEnum, 42)).toBe(false);
expect(Value.Check(TaskRiskEnum, null)).toBe(false);
});
});
// --- TaskImpactEnum ---
describe('TaskImpactEnum', () => {
const validValues = ['isolated', 'component', 'phase', 'project'] as const;
it('accepts each valid literal', () => {
for (const value of validValues) {
expect(Value.Check(TaskImpactEnum, value)).toBe(true);
}
});
it('rejects invalid values', () => {
expect(Value.Check(TaskImpactEnum, 'unknown')).toBe(false);
expect(Value.Check(TaskImpactEnum, '')).toBe(false);
expect(Value.Check(TaskImpactEnum, 42)).toBe(false);
expect(Value.Check(TaskImpactEnum, null)).toBe(false);
});
});
// --- TaskLevelEnum ---
describe('TaskLevelEnum', () => {
const validValues = ['planning', 'decomposition', 'implementation', 'review', 'research'] as const;
it('accepts each valid literal', () => {
for (const value of validValues) {
expect(Value.Check(TaskLevelEnum, value)).toBe(true);
}
});
it('rejects invalid values', () => {
expect(Value.Check(TaskLevelEnum, 'unknown')).toBe(false);
expect(Value.Check(TaskLevelEnum, '')).toBe(false);
expect(Value.Check(TaskLevelEnum, 42)).toBe(false);
expect(Value.Check(TaskLevelEnum, null)).toBe(false);
});
});
// --- TaskPriorityEnum ---
describe('TaskPriorityEnum', () => {
const validValues = ['low', 'medium', 'high', 'critical'] as const;
it('accepts each valid literal', () => {
for (const value of validValues) {
expect(Value.Check(TaskPriorityEnum, value)).toBe(true);
}
});
it('rejects invalid values', () => {
expect(Value.Check(TaskPriorityEnum, 'unknown')).toBe(false);
expect(Value.Check(TaskPriorityEnum, '')).toBe(false);
expect(Value.Check(TaskPriorityEnum, 42)).toBe(false);
expect(Value.Check(TaskPriorityEnum, null)).toBe(false);
});
});
// --- TaskStatusEnum ---
describe('TaskStatusEnum', () => {
const validValues = ['pending', 'in-progress', 'completed', 'failed', 'blocked'] as const;
it('accepts each valid literal', () => {
for (const value of validValues) {
expect(Value.Check(TaskStatusEnum, value)).toBe(true);
}
});
it('rejects invalid values', () => {
expect(Value.Check(TaskStatusEnum, 'unknown')).toBe(false);
expect(Value.Check(TaskStatusEnum, '')).toBe(false);
expect(Value.Check(TaskStatusEnum, 42)).toBe(false);
expect(Value.Check(TaskStatusEnum, null)).toBe(false);
});
});
});
describe('Nullable helper', () => {
it('accepts valid enum values', () => {
const NullableScope = Nullable(TaskScopeEnum);
expect(Value.Check(NullableScope, 'single')).toBe(true);
expect(Value.Check(NullableScope, 'system')).toBe(true);
});
it('accepts null', () => {
const NullableScope = Nullable(TaskScopeEnum);
expect(Value.Check(NullableScope, null)).toBe(true);
});
it('rejects undefined and invalid strings', () => {
const NullableScope = Nullable(TaskScopeEnum);
expect(Value.Check(NullableScope, 'invalid')).toBe(false);
expect(Value.Check(NullableScope, undefined)).toBe(false);
expect(Value.Check(NullableScope, 42)).toBe(false);
});
});
describe('Type alias correctness (compile-time)', () => {
// These tests verify that the type aliases resolve to the expected union types.
// We use type assertions to confirm the types are what we expect.
// If the types are wrong, TypeScript would fail to compile this file.
it('TaskScope type accepts valid values', () => {
const scope: TaskScope = 'single';
expect(scope).toBe('single');
});
it('TaskRisk type accepts valid values', () => {
const risk: TaskRisk = 'critical';
expect(risk).toBe('critical');
});
it('TaskImpact type accepts valid values', () => {
const impact: TaskImpact = 'project';
expect(impact).toBe('project');
});
it('TaskLevel type accepts valid values', () => {
const level: TaskLevel = 'implementation';
expect(level).toBe('implementation');
});
it('TaskPriority type accepts valid values', () => {
const priority: TaskPriority = 'high';
expect(priority).toBe('high');
});
it('TaskStatus type accepts valid values', () => {
const status: TaskStatus = 'in-progress';
expect(status).toBe('in-progress');
});
});
// --- TaskInput and DependencyEdge tests ---
import { TaskInput as TaskInputSchema, DependencyEdge as DependencyEdgeSchema } from '../src/schema/task.js';
// Type alias imports for compile-time verification
type TaskInputType = import('../src/schema/task.js').TaskInput;
type DependencyEdgeType = import('../src/schema/task.js').DependencyEdge;
describe('TaskInput schema', () => {
const minimal = { id: 'task-1', name: 'My Task', dependsOn: [] };
it('accepts minimal valid input (id, name, dependsOn only)', () => {
expect(Value.Check(TaskInputSchema, minimal)).toBe(true);
});
it('accepts dependsOn with multiple strings', () => {
expect(Value.Check(TaskInputSchema, { ...minimal, dependsOn: ['a', 'b', 'c'] })).toBe(true);
});
// --- Categorical fields: Optional + Nullable ---
it('accepts categorical field set to a valid enum value', () => {
expect(Value.Check(TaskInputSchema, { ...minimal, status: 'pending' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, scope: 'narrow' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, risk: 'medium' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, impact: 'component' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, level: 'implementation' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, priority: 'high' })).toBe(true);
});
it('accepts categorical field set to null (explicit null in YAML)', () => {
expect(Value.Check(TaskInputSchema, { ...minimal, status: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, scope: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, risk: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, impact: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, level: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, priority: null })).toBe(true);
});
it('accepts categorical field absent (undefined / missing key)', () => {
// minimal has no categorical fields — already tested above
expect(Value.Check(TaskInputSchema, minimal)).toBe(true);
});
it('rejects categorical field with invalid enum value', () => {
expect(Value.Check(TaskInputSchema, { ...minimal, status: 'unknown' })).toBe(false);
expect(Value.Check(TaskInputSchema, { ...minimal, scope: 'invalid' })).toBe(false);
expect(Value.Check(TaskInputSchema, { ...minimal, risk: 'bad' })).toBe(false);
});
// --- Metadata fields: Optional + Nullable ---
it('accepts metadata fields with valid values', () => {
expect(Value.Check(TaskInputSchema, { ...minimal, tags: ['a', 'b'] })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, assignee: 'alice' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, due: '2026-05-01' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, created: '2026-04-20' })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, modified: '2026-04-25' })).toBe(true);
});
it('accepts metadata fields set to null', () => {
expect(Value.Check(TaskInputSchema, { ...minimal, assignee: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, due: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, created: null })).toBe(true);
expect(Value.Check(TaskInputSchema, { ...minimal, modified: null })).toBe(true);
});
it('accepts tags field absent', () => {
expect(Value.Check(TaskInputSchema, { ...minimal })).toBe(true);
});
// --- Required fields ---
it('rejects missing id', () => {
expect(Value.Check(TaskInputSchema, { name: 'X', dependsOn: [] })).toBe(false);
});
it('rejects missing name', () => {
expect(Value.Check(TaskInputSchema, { id: '1', dependsOn: [] })).toBe(false);
});
it('rejects missing dependsOn', () => {
expect(Value.Check(TaskInputSchema, { id: '1', name: 'X' })).toBe(false);
});
it('rejects wrong types for required fields', () => {
expect(Value.Check(TaskInputSchema, { id: 1, name: 'X', dependsOn: [] })).toBe(false);
expect(Value.Check(TaskInputSchema, { id: '1', name: 2, dependsOn: [] })).toBe(false);
expect(Value.Check(TaskInputSchema, { id: '1', name: 'X', dependsOn: 'not-array' })).toBe(false);
});
// --- Full valid input ---
it('accepts fully populated valid input', () => {
const full = {
id: 'task-1',
name: 'My Task',
dependsOn: ['task-0'],
status: 'in-progress',
scope: 'narrow',
risk: 'medium',
impact: 'component',
level: 'implementation',
priority: 'high',
tags: ['backend', 'api'],
assignee: 'bob',
due: '2026-06-01',
created: '2026-04-01',
modified: '2026-04-27',
};
expect(Value.Check(TaskInputSchema, full)).toBe(true);
});
it('produces structured errors for invalid input', () => {
const errors = [...Value.Errors(TaskInputSchema, { id: 1, name: 2, dependsOn: 'nope' })];
expect(errors.length).toBeGreaterThan(0);
const paths = errors.map(e => e.path);
expect(paths).toContain('/id');
expect(paths).toContain('/name');
expect(paths).toContain('/dependsOn');
});
});
describe('DependencyEdge schema', () => {
const minimal = { from: 'task-a', to: 'task-b' };
it('accepts minimal valid edge (from, to only)', () => {
expect(Value.Check(DependencyEdgeSchema, minimal)).toBe(true);
});
it('accepts edge with qualityRetention', () => {
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 0.9 })).toBe(true);
});
it('accepts qualityRetention at boundary values 0.0 and 1.0', () => {
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 0.0 })).toBe(true);
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 1.0 })).toBe(true);
});
it('rejects missing from field', () => {
expect(Value.Check(DependencyEdgeSchema, { to: 'task-b' })).toBe(false);
});
it('rejects missing to field', () => {
expect(Value.Check(DependencyEdgeSchema, { from: 'task-a' })).toBe(false);
});
it('rejects wrong types for from/to', () => {
expect(Value.Check(DependencyEdgeSchema, { from: 1, to: 'task-b' })).toBe(false);
expect(Value.Check(DependencyEdgeSchema, { from: 'task-a', to: 2 })).toBe(false);
});
it('rejects wrong type for qualityRetention', () => {
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 'high' })).toBe(false);
});
it('allows qualityRetention absent (optional)', () => {
expect(Value.Check(DependencyEdgeSchema, minimal)).toBe(true);
});
it('produces structured errors for invalid input', () => {
const errors = [...Value.Errors(DependencyEdgeSchema, { from: 1, to: 2 })];
expect(errors.length).toBeGreaterThan(0);
const paths = errors.map(e => e.path);
expect(paths).toContain('/from');
expect(paths).toContain('/to');
});
});
describe('Type alias correctness — TaskInput and DependencyEdge (compile-time)', () => {
it('TaskInput type accepts a valid object', () => {
const input: TaskInputType = { id: 't1', name: 'T', dependsOn: [], risk: null };
expect(input.id).toBe('t1');
});
it('DependencyEdge type accepts a valid object', () => {
const edge: DependencyEdgeType = { from: 'a', to: 'b', qualityRetention: 0.8 };
expect(edge.from).toBe('a');
});
it('DependencyEdge type works without qualityRetention', () => {
const edge: DependencyEdgeType = { from: 'a', to: 'b' };
expect(edge.qualityRetention).toBeUndefined();
});
});
// Re-export Nullable from task.ts to verify the re-export works
import { Nullable as NullableFromTask } from '../src/schema/task.js';
describe('Nullable re-export from task.ts', () => {
it('is the same function as from enums.ts', () => {
expect(NullableFromTask).toBe(Nullable);
});
});
// Intentionally import type aliases to verify they exist at compile time
type TaskScope = import('../src/schema/enums.js').TaskScope;
type TaskRisk = import('../src/schema/enums.js').TaskRisk;
type TaskImpact = import('../src/schema/enums.js').TaskImpact;
type TaskLevel = import('../src/schema/enums.js').TaskLevel;
type TaskPriority = import('../src/schema/enums.js').TaskPriority;
type TaskStatus = import('../src/schema/enums.js').TaskStatus;
// --- Result schema tests ---
import {
RiskPathResult as RiskPathResultSchema,
DecomposeResult as DecomposeResultSchema,
WorkflowCostOptions as WorkflowCostOptionsSchema,
WorkflowCostResult as WorkflowCostResultSchema,
EvConfig as EvConfigSchema,
EvResult as EvResultSchema,
RiskDistributionResult as RiskDistributionResultSchema,
} from '../src/schema/results.js';
// Re-import type aliases for compile-time verification
type RiskPathResultType = import('../src/schema/results.js').RiskPathResult;
type DecomposeResultType = import('../src/schema/results.js').DecomposeResult;
type WorkflowCostOptionsType = import('../src/schema/results.js').WorkflowCostOptions;
type WorkflowCostResultType = import('../src/schema/results.js').WorkflowCostResult;
type EvConfigType = import('../src/schema/results.js').EvConfig;
type EvResultType = import('../src/schema/results.js').EvResult;
type RiskDistributionResultType = import('../src/schema/results.js').RiskDistributionResult;
describe('RiskPathResult schema', () => {
it('accepts valid input', () => {
expect(Value.Check(RiskPathResultSchema, { path: ['a', 'b', 'c'], totalRisk: 0.75 })).toBe(true);
});
it('accepts empty path', () => {
expect(Value.Check(RiskPathResultSchema, { path: [], totalRisk: 0 })).toBe(true);
});
it('rejects missing fields', () => {
expect(Value.Check(RiskPathResultSchema, { path: ['a'] })).toBe(false);
expect(Value.Check(RiskPathResultSchema, { totalRisk: 0.5 })).toBe(false);
});
it('rejects wrong types', () => {
expect(Value.Check(RiskPathResultSchema, { path: 'not-array', totalRisk: 0.5 })).toBe(false);
expect(Value.Check(RiskPathResultSchema, { path: ['a'], totalRisk: 'not-number' })).toBe(false);
});
});
describe('DecomposeResult schema', () => {
it('accepts shouldDecompose=true with reasons', () => {
expect(Value.Check(DecomposeResultSchema, { shouldDecompose: true, reasons: ['high risk', 'broad scope'] })).toBe(true);
});
it('accepts shouldDecompose=false with empty reasons', () => {
expect(Value.Check(DecomposeResultSchema, { shouldDecompose: false, reasons: [] })).toBe(true);
});
it('rejects missing fields', () => {
expect(Value.Check(DecomposeResultSchema, { shouldDecompose: true })).toBe(false);
expect(Value.Check(DecomposeResultSchema, { reasons: ['x'] })).toBe(false);
});
it('rejects wrong types', () => {
expect(Value.Check(DecomposeResultSchema, { shouldDecompose: 'yes', reasons: [] })).toBe(false);
expect(Value.Check(DecomposeResultSchema, { shouldDecompose: true, reasons: 'not-array' })).toBe(false);
});
});
describe('WorkflowCostOptions schema', () => {
it('accepts empty object (all fields optional)', () => {
expect(Value.Check(WorkflowCostOptionsSchema, {})).toBe(true);
});
it('accepts all fields specified', () => {
expect(Value.Check(WorkflowCostOptionsSchema, {
includeCompleted: true,
limit: 10,
propagationMode: 'independent',
defaultQualityRetention: 0.9,
})).toBe(true);
});
it('accepts dag-propagate propagationMode', () => {
expect(Value.Check(WorkflowCostOptionsSchema, { propagationMode: 'dag-propagate' })).toBe(true);
});
it('rejects invalid propagationMode', () => {
expect(Value.Check(WorkflowCostOptionsSchema, { propagationMode: 'invalid' })).toBe(false);
});
it('rejects wrong type for boolean field', () => {
expect(Value.Check(WorkflowCostOptionsSchema, { includeCompleted: 'yes' })).toBe(false);
});
it('rejects wrong type for number fields', () => {
expect(Value.Check(WorkflowCostOptionsSchema, { limit: 'ten' })).toBe(false);
expect(Value.Check(WorkflowCostOptionsSchema, { defaultQualityRetention: 'high' })).toBe(false);
});
});
describe('WorkflowCostResult schema', () => {
const validResult = {
tasks: [{
taskId: 'task-1',
name: 'My Task',
ev: 3.5,
pIntrinsic: 0.8,
pEffective: 0.75,
probability: 0.75,
scopeCost: 2.0,
impactWeight: 1.5,
}],
totalEv: 3.5,
averageEv: 3.5,
propagationMode: 'independent',
};
it('accepts valid result with independent mode', () => {
expect(Value.Check(WorkflowCostResultSchema, validResult)).toBe(true);
});
it('accepts valid result with dag-propagate mode', () => {
expect(Value.Check(WorkflowCostResultSchema, { ...validResult, propagationMode: 'dag-propagate' })).toBe(true);
});
it('accepts empty tasks array', () => {
expect(Value.Check(WorkflowCostResultSchema, {
tasks: [],
totalEv: 0,
averageEv: 0,
propagationMode: 'independent',
})).toBe(true);
});
it('rejects missing fields', () => {
expect(Value.Check(WorkflowCostResultSchema, { tasks: [], totalEv: 0, averageEv: 0 })).toBe(false);
});
it('rejects invalid propagationMode', () => {
expect(Value.Check(WorkflowCostResultSchema, { ...validResult, propagationMode: 'invalid' })).toBe(false);
});
it('rejects task entry with missing fields', () => {
const incomplete = {
tasks: [{ taskId: 't1', name: 'T1' }],
totalEv: 0,
averageEv: 0,
propagationMode: 'independent',
};
expect(Value.Check(WorkflowCostResultSchema, incomplete)).toBe(false);
});
});
describe('EvConfig schema', () => {
it('accepts empty object (all fields optional)', () => {
expect(Value.Check(EvConfigSchema, {})).toBe(true);
});
it('accepts all fields specified', () => {
expect(Value.Check(EvConfigSchema, {
retries: 3,
fallbackCost: 10,
timeLost: 5,
valueRate: 0.5,
})).toBe(true);
});
it('rejects wrong types', () => {
expect(Value.Check(EvConfigSchema, { retries: 'three' })).toBe(false);
expect(Value.Check(EvConfigSchema, { fallbackCost: true })).toBe(false);
});
it('rejects unknown properties (additionalProperties)', () => {
expect(Value.Check(EvConfigSchema, { unknownField: 42 })).toBe(true);
});
});
describe('EvResult schema', () => {
it('accepts valid input', () => {
expect(Value.Check(EvResultSchema, { ev: 3.5, pSuccess: 0.8, expectedRetries: 1.2 })).toBe(true);
});
it('rejects missing fields', () => {
expect(Value.Check(EvResultSchema, { ev: 3.5, pSuccess: 0.8 })).toBe(false);
});
it('rejects wrong types', () => {
expect(Value.Check(EvResultSchema, { ev: 'high', pSuccess: 0.8, expectedRetries: 1 })).toBe(false);
});
});
describe('RiskDistributionResult schema', () => {
it('accepts valid distribution', () => {
expect(Value.Check(RiskDistributionResultSchema, {
trivial: ['t1'],
low: ['t2', 't3'],
medium: [],
high: ['t4'],
critical: [],
unspecified: ['t5'],
})).toBe(true);
});
it('accepts all empty arrays', () => {
expect(Value.Check(RiskDistributionResultSchema, {
trivial: [],
low: [],
medium: [],
high: [],
critical: [],
unspecified: [],
})).toBe(true);
});
it('rejects missing fields', () => {
expect(Value.Check(RiskDistributionResultSchema, {
trivial: [],
low: [],
medium: [],
high: [],
critical: [],
})).toBe(false);
});
it('rejects non-array values', () => {
expect(Value.Check(RiskDistributionResultSchema, {
trivial: 't1',
low: [],
medium: [],
high: [],
critical: [],
unspecified: [],
})).toBe(false);
});
it('rejects non-string array elements', () => {
expect(Value.Check(RiskDistributionResultSchema, {
trivial: [123],
low: [],
medium: [],
high: [],
critical: [],
unspecified: [],
})).toBe(false);
});
});
describe('Result type alias correctness (compile-time)', () => {
it('RiskPathResult type accepts valid values', () => {
const result: RiskPathResultType = { path: ['a'], totalRisk: 0.5 };
expect(result.totalRisk).toBe(0.5);
});
it('DecomposeResult type accepts valid values', () => {
const result: DecomposeResultType = { shouldDecompose: true, reasons: ['high risk'] };
expect(result.shouldDecompose).toBe(true);
});
it('WorkflowCostOptions type accepts valid values', () => {
const opts: WorkflowCostOptionsType = { propagationMode: 'dag-propagate', limit: 5 };
expect(opts.propagationMode).toBe('dag-propagate');
});
it('WorkflowCostResult type accepts valid values', () => {
const result: WorkflowCostResultType = {
tasks: [],
totalEv: 0,
averageEv: 0,
propagationMode: 'independent',
};
expect(result.propagationMode).toBe('independent');
});
it('EvConfig type accepts valid values', () => {
const config: EvConfigType = { retries: 3, fallbackCost: 10 };
expect(config.retries).toBe(3);
});
it('EvResult type accepts valid values', () => {
const result: EvResultType = { ev: 2.5, pSuccess: 0.9, expectedRetries: 0.5 };
expect(result.ev).toBe(2.5);
});
it('RiskDistributionResult type accepts valid values', () => {
const result: RiskDistributionResultType = {
trivial: [],
low: [],
medium: [],
high: [],
critical: [],
unspecified: [],
};
expect(result.unspecified).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Graph schema tests
// ---------------------------------------------------------------------------
describe('TaskGraphNodeAttributes', () => {
it('accepts a valid node with only required fields', () => {
const node = { name: 'my-task' };
expect(Value.Check(TaskGraphNodeAttributes, node)).toBe(true);
});
it('accepts a node with all optional categorical enum fields', () => {
const node = {
name: 'my-task',
scope: 'narrow',
risk: 'high',
impact: 'component',
level: 'implementation',
priority: 'critical',
status: 'in-progress',
};
expect(Value.Check(TaskGraphNodeAttributes, node)).toBe(true);
});
it('rejects a node missing the required name field', () => {
const node = { scope: 'narrow' };
expect(Value.Check(TaskGraphNodeAttributes, node)).toBe(false);
});
it('rejects invalid categorical enum values', () => {
expect(Value.Check(TaskGraphNodeAttributes, { name: 't', scope: 'invalid' })).toBe(false);
expect(Value.Check(TaskGraphNodeAttributes, { name: 't', risk: 'unknown' })).toBe(false);
expect(Value.Check(TaskGraphNodeAttributes, { name: 't', status: 'done' })).toBe(false);
});
it('rejects non-object values', () => {
expect(Value.Check(TaskGraphNodeAttributes, null)).toBe(false);
expect(Value.Check(TaskGraphNodeAttributes, 'string')).toBe(false);
expect(Value.Check(TaskGraphNodeAttributes, 42)).toBe(false);
});
it('does NOT carry tags/assignee/due fields (analysis-only)', () => {
const schema = TaskGraphNodeAttributes as any;
const props = schema?.properties;
expect(props).toBeDefined();
expect(props.tags).toBeUndefined();
expect(props.assignee).toBeUndefined();
expect(props.due).toBeUndefined();
});
});
describe('TaskGraphNodeAttributesUpdate', () => {
it('accepts an empty object (all fields optional)', () => {
expect(Value.Check(TaskGraphNodeAttributesUpdate, {})).toBe(true);
});
it('accepts a partial object with only name', () => {
expect(Value.Check(TaskGraphNodeAttributesUpdate, { name: 't' })).toBe(true);
});
it('accepts a partial object with only optional categorical fields', () => {
expect(Value.Check(TaskGraphNodeAttributesUpdate, { risk: 'low', status: 'pending' })).toBe(true);
});
it('rejects invalid categorical enum values in partial', () => {
expect(Value.Check(TaskGraphNodeAttributesUpdate, { risk: 'invalid' })).toBe(false);
});
});
describe('TaskGraphEdgeAttributes', () => {
it('accepts an empty object (qualityRetention is optional)', () => {
expect(Value.Check(TaskGraphEdgeAttributes, {})).toBe(true);
});
it('accepts a valid qualityRetention number', () => {
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 0.9 })).toBe(true);
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 0.0 })).toBe(true);
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 1.0 })).toBe(true);
});
it('rejects non-number qualityRetention', () => {
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 'high' })).toBe(false);
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: null })).toBe(false);
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: true })).toBe(false);
});
});
describe('SerializedGraph generic factory', () => {
it('produces a schema that validates graphology JSON format', () => {
const graph = {
attributes: {},
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
nodes: [
{ key: 'task-a', attributes: { name: 'Task A' } },
{ key: 'task-b', attributes: { name: 'Task B', scope: 'narrow' as const } },
],
edges: [
{ key: 'edge-1', source: 'task-a', target: 'task-b', attributes: { qualityRetention: 0.9 } },
],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(true);
});
it('accepts empty nodes and edges arrays', () => {
const graph = {
attributes: {},
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
nodes: [],
edges: [],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(true);
});
it('rejects wrong options.type', () => {
const graph = {
attributes: {},
options: { type: 'undirected', multi: false, allowSelfLoops: false },
nodes: [],
edges: [],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
});
it('rejects wrong options.multi', () => {
const graph = {
attributes: {},
options: { type: 'directed' as const, multi: true, allowSelfLoops: false },
nodes: [],
edges: [],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
});
it('rejects wrong options.allowSelfLoops', () => {
const graph = {
attributes: {},
options: { type: 'directed' as const, multi: false, allowSelfLoops: true },
nodes: [],
edges: [],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
});
it('rejects node with invalid attributes', () => {
const graph = {
attributes: {},
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
nodes: [{ key: 'bad', attributes: { scope: 'invalid' } }],
edges: [],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
});
it('rejects edge with invalid source/target', () => {
const graph = {
attributes: {},
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
nodes: [],
edges: [{ key: 'e1', source: 42, target: 'b', attributes: {} }],
};
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
});
it('has no schema version field', () => {
const schema = TaskGraphSerialized as any;
const props = schema?.properties;
expect(props).toBeDefined();
expect(props.version).toBeUndefined();
expect(props.schemaVersion).toBeUndefined();
});
it('can be composed with different attribute schemas (is generic)', () => {
const CustomGraph = SerializedGraph(
TaskGraphNodeAttributes,
TaskGraphEdgeAttributes,
{ type: 'object', properties: { label: { type: 'string' } } } as any,
);
expect(CustomGraph).toBeDefined();
expect((CustomGraph as any).properties).toBeDefined();
});
});
describe('TaskGraphSerialized type alias (compile-time)', () => {
it('type alias resolves correctly for a valid graph', () => {
const graph: TaskGraphSerialized = {
attributes: {},
options: { type: 'directed', multi: false, allowSelfLoops: false },
nodes: [{ key: 'a', attributes: { name: 'A' } }],
edges: [],
};
expect(graph.options.type).toBe('directed');
});
});
// Import graph type aliases for compile-time verification
type TaskGraphNodeAttributes = import('../src/schema/graph.js').TaskGraphNodeAttributes;
type TaskGraphNodeAttributesUpdate = import('../src/schema/graph.js').TaskGraphNodeAttributesUpdate;
type TaskGraphEdgeAttributes = import('../src/schema/graph.js').TaskGraphEdgeAttributes;
type TaskGraphSerialized = import('../src/schema/graph.js').TaskGraphSerialized;