Просмотр исходного кода

feat: add event graph utility functions with validation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 месяцев назад
Родитель
Сommit
9e93654b95
2 измененных файлов с 202 добавлено и 0 удалено
  1. 114 0
      src/app/lib/__tests__/event-graph-utils.test.ts
  2. 88 0
      src/app/lib/event-graph-utils.ts

+ 114 - 0
src/app/lib/__tests__/event-graph-utils.test.ts

@@ -0,0 +1,114 @@
+import { describe, it, expect } from 'vitest';
+import {
+  createEventNode,
+  reorderNarrative,
+  assignEventsToChapter,
+  validateEventGraph,
+} from '@/app/lib/event-graph-utils';
+import type { EventNode, EventEdge } from '@/app/lib/novel-types';
+
+function makeEvent(overrides: Partial<EventNode> = {}): EventNode {
+  return {
+    id: 'evt-1',
+    title: 'Test Event',
+    description: 'A test event',
+    timeLabel: 'Day 1',
+    location: 'Test Location',
+    characters: ['主角'],
+    eventType: 'main',
+    chronologicalOrder: 1,
+    narrativeOrder: 1,
+    parentEventIds: [],
+    tags: [],
+    status: 'pending',
+    ...overrides,
+  };
+}
+
+describe('createEventNode', () => {
+  it('generates a UUID and sets default values', () => {
+    const node = createEventNode({ title: 'New Event' });
+    expect(node.id).toMatch(/^[0-9a-f-]{36}$/);
+    expect(node.title).toBe('New Event');
+    expect(node.status).toBe('pending');
+    expect(node.eventType).toBe('main');
+  });
+});
+
+describe('reorderNarrative', () => {
+  it('updates narrativeOrder for each event by its new index', () => {
+    const events = [
+      makeEvent({ id: 'a', narrativeOrder: 0 }),
+      makeEvent({ id: 'b', narrativeOrder: 1 }),
+      makeEvent({ id: 'c', narrativeOrder: 2 }),
+    ];
+    const result = reorderNarrative(events, ['c', 'a', 'b']);
+    expect(result.find(e => e.id === 'c')!.narrativeOrder).toBe(0);
+    expect(result.find(e => e.id === 'a')!.narrativeOrder).toBe(1);
+    expect(result.find(e => e.id === 'b')!.narrativeOrder).toBe(2);
+  });
+
+  it('preserves events not in the order list', () => {
+    const events = [makeEvent({ id: 'a' }), makeEvent({ id: 'b' })];
+    const result = reorderNarrative(events, ['b']);
+    expect(result.find(e => e.id === 'a')!.narrativeOrder).toBe(1);
+    expect(result.find(e => e.id === 'b')!.narrativeOrder).toBe(0);
+  });
+});
+
+describe('assignEventsToChapter', () => {
+  it('sets chapterIndex and status on assigned events', () => {
+    const events = [
+      makeEvent({ id: 'a' }),
+      makeEvent({ id: 'b' }),
+      makeEvent({ id: 'c' }),
+    ];
+    const result = assignEventsToChapter(events, ['a', 'c'], 3);
+    expect(result.find(e => e.id === 'a')!.chapterIndex).toBe(3);
+    expect(result.find(e => e.id === 'a')!.status).toBe('written');
+    expect(result.find(e => e.id === 'b')!.chapterIndex).toBeUndefined();
+    expect(result.find(e => e.id === 'b')!.status).toBe('pending');
+  });
+});
+
+describe('validateEventGraph', () => {
+  it('returns no errors for a valid graph', () => {
+    const events = [
+      makeEvent({ id: 'a', chronologicalOrder: 1 }),
+      makeEvent({ id: 'b', chronologicalOrder: 2, parentEventIds: ['a'] }),
+    ];
+    const edges: EventEdge[] = [
+      { sourceId: 'a', targetId: 'b', relationType: 'causal' },
+    ];
+    const result = validateEventGraph(events, edges);
+    expect(result.errors).toHaveLength(0);
+  });
+
+  it('detects orphan nodes with no connections', () => {
+    const events = [makeEvent({ id: 'a' }), makeEvent({ id: 'b' })];
+    const edges: EventEdge[] = [];
+    const result = validateEventGraph(events, edges);
+    expect(result.warnings.some(w => w.includes('孤立'))).toBe(true);
+  });
+
+  it('detects dangling parent references', () => {
+    const events = [
+      makeEvent({ id: 'a', parentEventIds: ['nonexistent'] }),
+    ];
+    const edges: EventEdge[] = [];
+    const result = validateEventGraph(events, edges);
+    expect(result.errors.length).toBeGreaterThan(0);
+  });
+
+  it('warns about inverted causal order', () => {
+    const events = [
+      makeEvent({ id: 'a', title: 'Effect', chronologicalOrder: 2 }),
+      makeEvent({ id: 'b', title: 'Cause', chronologicalOrder: 1 }),
+    ];
+    const edges: EventEdge[] = [
+      { sourceId: 'a', targetId: 'b', relationType: 'causal' },
+    ];
+    const result = validateEventGraph(events, edges);
+    expect(result.warnings.some(w => w.includes('倒置'))).toBe(true);
+  });
+});

+ 88 - 0
src/app/lib/event-graph-utils.ts

@@ -0,0 +1,88 @@
+import type { EventNode, EventEdge } from '@/app/lib/novel-types';
+
+export function createEventNode(
+  partial: Partial<EventNode> & { title: string }
+): EventNode {
+  return {
+    id: crypto.randomUUID(),
+    description: '',
+    timeLabel: '',
+    location: '',
+    characters: [],
+    eventType: 'main',
+    chronologicalOrder: 0,
+    narrativeOrder: 0,
+    parentEventIds: [],
+    tags: [],
+    status: 'pending',
+    ...partial,
+  };
+}
+
+export function reorderNarrative(
+  events: EventNode[],
+  orderedIds: string[]
+): EventNode[] {
+  const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
+  return events.map(e => ({
+    ...e,
+    narrativeOrder: orderMap.has(e.id) ? orderMap.get(e.id)! : e.narrativeOrder,
+  }));
+}
+
+export function assignEventsToChapter(
+  events: EventNode[],
+  eventIds: string[],
+  chapterIndex: number
+): EventNode[] {
+  const idSet = new Set(eventIds);
+  return events.map(e =>
+    idSet.has(e.id)
+      ? { ...e, chapterIndex, status: 'written' as const }
+      : e
+  );
+}
+
+export function validateEventGraph(
+  events: EventNode[],
+  edges: EventEdge[]
+): { errors: string[]; warnings: string[] } {
+  const errors: string[] = [];
+  const warnings: string[] = [];
+  const eventIds = new Set(events.map(e => e.id));
+
+  // Check dangling parent references
+  for (const event of events) {
+    for (const parentId of event.parentEventIds) {
+      if (!eventIds.has(parentId)) {
+        errors.push(`事件 "${event.title}" 引用了不存在的前置事件: ${parentId}`);
+      }
+    }
+  }
+
+  // Check orphan nodes (no connections)
+  const connectedIds = new Set<string>();
+  for (const edge of edges) {
+    connectedIds.add(edge.sourceId);
+    connectedIds.add(edge.targetId);
+  }
+  const orphans = events.filter(e => !connectedIds.has(e.id));
+  if (orphans.length > 0 && events.length > 1) {
+    warnings.push(`发现 ${orphans.length} 个孤立事件(无因果或时序关联),建议添加连线。`);
+  }
+
+  // Check chronological order consistency
+  for (const edge of edges) {
+    const source = events.find(e => e.id === edge.sourceId);
+    const target = events.find(e => e.id === edge.targetId);
+    if (source && target && edge.relationType === 'causal') {
+      if (source.chronologicalOrder > target.chronologicalOrder) {
+        warnings.push(
+          `因果边 "${source.title}" → "${target.title}" 的时间顺序倒置(先有果后有因)。`
+        );
+      }
+    }
+  }
+
+  return { errors, warnings };
+}