mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 09:19:39 +02:00
Add spiral-db integration to Contacts as the third app using pixel-based spiral visualization. Contacts are encoded with name, company, city, and email/phone flags. Changes: - Add createContactSchema() to spiral-db with bool fields for hasEmail/hasPhone and nullable company/city - Create Svelte 5 spiral store with importContacts from contactsStore - Add SpiralCanvas component and /spiral route - Wire up navigation (Ctrl+5) with auto-import on mount - Favorites show as starred entries with gold border Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
293 lines
8.6 KiB
TypeScript
293 lines
8.6 KiB
TypeScript
/**
|
|
* Schema Tests
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
encodeSchema,
|
|
decodeSchema,
|
|
getSchemaPixelCount,
|
|
createTodoSchema,
|
|
createQuoteSchema,
|
|
createContactSchema,
|
|
validateRecord,
|
|
getFieldNames,
|
|
} from './schema.js';
|
|
|
|
// =============================================================================
|
|
// SCHEMA ENCODE / DECODE
|
|
// =============================================================================
|
|
|
|
describe('Schema Encode/Decode', () => {
|
|
it('should round-trip the todo schema', () => {
|
|
const schema = createTodoSchema();
|
|
const pixels = encodeSchema(schema);
|
|
const fieldNames = getFieldNames(schema);
|
|
const decoded = decodeSchema(pixels, fieldNames);
|
|
|
|
expect(decoded.version).toBe(schema.version);
|
|
expect(decoded.fields.length).toBe(schema.fields.length);
|
|
|
|
for (let i = 0; i < schema.fields.length; i++) {
|
|
expect(decoded.fields[i].name).toBe(schema.fields[i].name);
|
|
expect(decoded.fields[i].type).toBe(schema.fields[i].type);
|
|
expect(decoded.fields[i].maxLength).toBe(schema.fields[i].maxLength);
|
|
expect(decoded.fields[i].nullable).toBe(schema.fields[i].nullable ?? false);
|
|
}
|
|
});
|
|
|
|
it('should encode a minimal schema (1 field)', () => {
|
|
const schema = {
|
|
version: 1,
|
|
name: 'minimal',
|
|
fields: [{ name: 'id', type: 'int' as const, maxLength: 8 }],
|
|
};
|
|
const pixels = encodeSchema(schema);
|
|
expect(pixels.length).toBeGreaterThan(0);
|
|
|
|
const decoded = decodeSchema(pixels, ['id']);
|
|
expect(decoded.fields).toHaveLength(1);
|
|
expect(decoded.fields[0].type).toBe('int');
|
|
expect(decoded.fields[0].maxLength).toBe(8);
|
|
});
|
|
|
|
it('should handle nullable fields correctly', () => {
|
|
const schema = {
|
|
version: 2,
|
|
name: 'nullable_test',
|
|
fields: [
|
|
{ name: 'required', type: 'string' as const, maxLength: 100 },
|
|
{ name: 'optional', type: 'string' as const, maxLength: 100, nullable: true },
|
|
],
|
|
};
|
|
const pixels = encodeSchema(schema);
|
|
const decoded = decodeSchema(pixels, ['required', 'optional']);
|
|
|
|
expect(decoded.fields[0].nullable).toBe(false);
|
|
expect(decoded.fields[1].nullable).toBe(true);
|
|
});
|
|
|
|
it('should preserve version number', () => {
|
|
const schema = { version: 511, name: 'max_ver', fields: [] };
|
|
const pixels = encodeSchema(schema);
|
|
const decoded = decodeSchema(pixels, []);
|
|
expect(decoded.version).toBe(511);
|
|
});
|
|
});
|
|
|
|
describe('getSchemaPixelCount', () => {
|
|
it('should calculate pixel count for todo schema', () => {
|
|
const schema = createTodoSchema();
|
|
const count = getSchemaPixelCount(schema);
|
|
const actualPixels = encodeSchema(schema);
|
|
expect(count).toBe(actualPixels.length);
|
|
});
|
|
|
|
it('should return at least 1 for empty schema', () => {
|
|
const schema = { version: 0, name: 'empty', fields: [] };
|
|
const count = getSchemaPixelCount(schema);
|
|
expect(count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// VALIDATE RECORD
|
|
// =============================================================================
|
|
|
|
describe('validateRecord', () => {
|
|
const schema = createTodoSchema();
|
|
|
|
const validTodo = {
|
|
id: 0,
|
|
status: 0,
|
|
priority: 1,
|
|
createdAt: new Date(),
|
|
dueDate: null,
|
|
completedAt: null,
|
|
title: 'Test',
|
|
description: null,
|
|
tags: [],
|
|
};
|
|
|
|
it('should accept a valid record', () => {
|
|
const result = validateRecord(schema, validTodo);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it('should reject missing required field', () => {
|
|
const record = { ...validTodo };
|
|
delete (record as Record<string, unknown>).title;
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors).toContain("Field 'title' is required");
|
|
});
|
|
|
|
it('should allow null for nullable fields', () => {
|
|
const record = { ...validTodo, dueDate: null, completedAt: null, description: null };
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should reject non-integer for int field', () => {
|
|
const record = { ...validTodo, priority: 1.5 };
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('priority'))).toBe(true);
|
|
});
|
|
|
|
it('should reject out-of-range int', () => {
|
|
const record = { ...validTodo, id: 5000 }; // max 4095 for 12-bit
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('out of range'))).toBe(true);
|
|
});
|
|
|
|
it('should reject negative int', () => {
|
|
const record = { ...validTodo, priority: -1 };
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it('should reject string too long', () => {
|
|
const record = { ...validTodo, title: 'x'.repeat(256) }; // max 255
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('too long'))).toBe(true);
|
|
});
|
|
|
|
it('should reject wrong type for timestamp', () => {
|
|
const record = { ...validTodo, createdAt: '2025-01-01' };
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('must be a Date'))).toBe(true);
|
|
});
|
|
|
|
it('should reject non-array for array field', () => {
|
|
const record = { ...validTodo, tags: 'not-array' };
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('must be an array'))).toBe(true);
|
|
});
|
|
|
|
it('should reject array with too many items', () => {
|
|
const record = { ...validTodo, tags: [1, 2, 3, 4, 5, 6, 7, 8, 9] }; // max 8
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('too many items'))).toBe(true);
|
|
});
|
|
|
|
it('should collect multiple errors', () => {
|
|
const record = {
|
|
id: -1,
|
|
status: 'invalid',
|
|
priority: 100,
|
|
createdAt: 'not-a-date',
|
|
dueDate: null,
|
|
completedAt: null,
|
|
title: 123,
|
|
description: null,
|
|
tags: 'not-array',
|
|
};
|
|
const result = validateRecord(schema, record);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(3);
|
|
});
|
|
});
|
|
|
|
describe('Quote Schema', () => {
|
|
it('should create quote schema with correct fields', () => {
|
|
const schema = createQuoteSchema();
|
|
expect(schema.name).toBe('quote');
|
|
expect(schema.version).toBe(1);
|
|
expect(schema.fields).toHaveLength(8);
|
|
expect(schema.fields.map((f) => f.name)).toEqual([
|
|
'id',
|
|
'status',
|
|
'category',
|
|
'language',
|
|
'createdAt',
|
|
'quoteId',
|
|
'author',
|
|
'text',
|
|
]);
|
|
});
|
|
|
|
it('should round-trip quote schema encode/decode', () => {
|
|
const schema = createQuoteSchema();
|
|
const pixels = encodeSchema(schema);
|
|
const names = getFieldNames(schema);
|
|
const decoded = decodeSchema(pixels, names);
|
|
expect(decoded.fields.length).toBe(schema.fields.length);
|
|
for (let i = 0; i < schema.fields.length; i++) {
|
|
expect(decoded.fields[i].type).toBe(schema.fields[i].type);
|
|
expect(decoded.fields[i].maxLength).toBe(schema.fields[i].maxLength);
|
|
}
|
|
});
|
|
|
|
it('should validate a valid quote record', () => {
|
|
const schema = createQuoteSchema();
|
|
const result = validateRecord(schema, {
|
|
id: 0,
|
|
status: 0,
|
|
category: 3,
|
|
language: 1,
|
|
createdAt: new Date(),
|
|
quoteId: 'q-123',
|
|
author: 'Goethe',
|
|
text: 'Ein kluges Wort',
|
|
});
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Contact Schema', () => {
|
|
it('should create contact schema with correct fields', () => {
|
|
const schema = createContactSchema();
|
|
expect(schema.name).toBe('contact');
|
|
expect(schema.fields).toHaveLength(8);
|
|
expect(schema.fields.map((f) => f.name)).toEqual([
|
|
'id',
|
|
'status',
|
|
'hasEmail',
|
|
'hasPhone',
|
|
'createdAt',
|
|
'name',
|
|
'company',
|
|
'city',
|
|
]);
|
|
});
|
|
|
|
it('should validate a valid contact record', () => {
|
|
const schema = createContactSchema();
|
|
const result = validateRecord(schema, {
|
|
id: 0,
|
|
status: 0,
|
|
hasEmail: true,
|
|
hasPhone: false,
|
|
createdAt: new Date(),
|
|
name: 'Max Mustermann',
|
|
company: null,
|
|
city: null,
|
|
});
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should mark company and city as nullable', () => {
|
|
const schema = createContactSchema();
|
|
const companyField = schema.fields.find((f) => f.name === 'company');
|
|
const cityField = schema.fields.find((f) => f.name === 'city');
|
|
expect(companyField?.nullable).toBe(true);
|
|
expect(cityField?.nullable).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getFieldNames', () => {
|
|
it('should return field names in order', () => {
|
|
const schema = createTodoSchema();
|
|
const names = getFieldNames(schema);
|
|
expect(names[0]).toBe('id');
|
|
expect(names[names.length - 1]).toBe('tags');
|
|
expect(names.length).toBe(schema.fields.length);
|
|
});
|
|
});
|