Implements graph attribute schemas and the SerializedGraph generic factory parameterized with <N, E, G> following the graphology JSON format. - TaskGraphNodeAttributes: name + optional categorical enums (scope, risk, impact, level, priority, status) — analysis-relevant metadata only - TaskGraphNodeAttributesUpdate: Type.Partial(TaskGraphNodeAttributes) - TaskGraphEdgeAttributes: optional qualityRetention number - SerializedGraph<N, E, G>: generic factory for graphology JSON format - TaskGraphSerialized: concrete instantiation with empty graph attributes - No schema version field per spec 35 new tests covering validation, rejection, and compile-time type safety.
396 lines
14 KiB
TypeScript
396 lines
14 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');
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)', () => {
|
|
// Extra properties are ignored by Value.Check (JSON Schema allows them by default)
|
|
// but the schema itself should not define those fields
|
|
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' } }], // missing name
|
|
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)', () => {
|
|
// Verify SerializedGraph is callable as a factory with different schemas
|
|
const CustomGraph = SerializedGraph(
|
|
TaskGraphNodeAttributes,
|
|
TaskGraphEdgeAttributes,
|
|
// Use a non-empty graph attributes schema to verify genericity
|
|
{ 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', () => {
|
|
// This verifies the type alias exists and is usable at compile time
|
|
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; |