@auto-engineer/narrative 1.3.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +16 -0
  5. package/dist/src/index.d.ts +3 -2
  6. package/dist/src/index.d.ts.map +1 -1
  7. package/dist/src/index.js +2 -1
  8. package/dist/src/index.js.map +1 -1
  9. package/dist/src/loader/ts-utils.d.ts +2 -2
  10. package/dist/src/loader/ts-utils.d.ts.map +1 -1
  11. package/dist/src/loader/ts-utils.js +7 -1
  12. package/dist/src/loader/ts-utils.js.map +1 -1
  13. package/dist/src/schema.d.ts +226 -20
  14. package/dist/src/schema.d.ts.map +1 -1
  15. package/dist/src/schema.js +6 -3
  16. package/dist/src/schema.js.map +1 -1
  17. package/dist/src/transformers/model-to-narrative/generators/imports.d.ts.map +1 -1
  18. package/dist/src/transformers/model-to-narrative/generators/imports.js +1 -0
  19. package/dist/src/transformers/model-to-narrative/generators/imports.js.map +1 -1
  20. package/dist/src/transformers/model-to-narrative/generators/types.d.ts +1 -1
  21. package/dist/src/transformers/model-to-narrative/generators/types.d.ts.map +1 -1
  22. package/dist/src/transformers/model-to-narrative/generators/types.js +2 -1
  23. package/dist/src/transformers/model-to-narrative/generators/types.js.map +1 -1
  24. package/dist/src/transformers/narrative-to-model/index.js.map +1 -1
  25. package/dist/src/transformers/narrative-to-model/messages.d.ts +1 -1
  26. package/dist/src/transformers/narrative-to-model/messages.d.ts.map +1 -1
  27. package/dist/src/transformers/narrative-to-model/messages.js +9 -1
  28. package/dist/src/transformers/narrative-to-model/messages.js.map +1 -1
  29. package/dist/src/transformers/narrative-to-model/spec-processors.d.ts +22 -1
  30. package/dist/src/transformers/narrative-to-model/spec-processors.d.ts.map +1 -1
  31. package/dist/src/transformers/narrative-to-model/spec-processors.js +81 -0
  32. package/dist/src/transformers/narrative-to-model/spec-processors.js.map +1 -1
  33. package/dist/src/transformers/narrative-to-model/type-inference.d.ts +1 -1
  34. package/dist/src/transformers/narrative-to-model/type-inference.d.ts.map +1 -1
  35. package/dist/src/transformers/narrative-to-model/type-inference.js.map +1 -1
  36. package/dist/src/types.d.ts +4 -0
  37. package/dist/src/types.d.ts.map +1 -1
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +4 -4
  40. package/src/index.ts +4 -0
  41. package/src/loader/ts-utils.ts +16 -10
  42. package/src/model-to-narrative.specs.ts +53 -0
  43. package/src/schema.ts +7 -2
  44. package/src/transformers/model-to-narrative/generators/imports.ts +1 -0
  45. package/src/transformers/model-to-narrative/generators/types.ts +4 -5
  46. package/src/transformers/narrative-to-model/index.ts +5 -5
  47. package/src/transformers/narrative-to-model/messages.ts +12 -3
  48. package/src/transformers/narrative-to-model/spec-processors.specs.ts +241 -0
  49. package/src/transformers/narrative-to-model/spec-processors.ts +91 -4
  50. package/src/transformers/narrative-to-model/type-inference.ts +4 -4
  51. package/src/types.ts +5 -0
package/package.json CHANGED
@@ -23,9 +23,9 @@
23
23
  "typescript": "^5.9.2",
24
24
  "zod": "^3.22.4",
25
25
  "zod-to-json-schema": "^3.22.3",
26
- "@auto-engineer/id": "1.3.4",
27
- "@auto-engineer/file-store": "1.3.4",
28
- "@auto-engineer/message-bus": "1.3.4"
26
+ "@auto-engineer/file-store": "1.4.0",
27
+ "@auto-engineer/id": "1.4.0",
28
+ "@auto-engineer/message-bus": "1.4.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.0.0",
@@ -35,7 +35,7 @@
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  },
38
- "version": "1.3.4",
38
+ "version": "1.4.0",
39
39
  "scripts": {
40
40
  "build": "tsx scripts/build.ts",
41
41
  "test": "vitest run --reporter=dot",
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export type {
19
19
  Integration,
20
20
  MessageTarget,
21
21
  Origin,
22
+ Query,
22
23
  State,
23
24
  } from './types';
24
25
  export { createIntegration } from './types';
@@ -81,6 +82,7 @@ export {
81
82
  modelSchema,
82
83
  NarrativeNamesSchema as NarrativeNamesSystemSchema,
83
84
  NarrativeSchema,
85
+ QuerySchema,
84
86
  QuerySliceSchema,
85
87
  ReactSliceSchema,
86
88
  RuleSchema,
@@ -135,3 +137,5 @@ export type MessageRef = z.infer<typeof MessageRefSchema>;
135
137
  // ID assignment utilities
136
138
  export { addAutoIds, hasAllIds } from './id';
137
139
  export type { ClientSpecNode } from './schema';
140
+
141
+ export { detectQueryAction, extractQueryNameFromRequest } from './transformers/narrative-to-model/spec-processors';
@@ -77,7 +77,7 @@ export function parseImports(ts: typeof import('typescript'), fileName: string,
77
77
 
78
78
  export interface TypeInfo {
79
79
  stringLiteral: string;
80
- classification?: 'command' | 'event' | 'state';
80
+ classification?: 'command' | 'event' | 'state' | 'query';
81
81
  dataFields?: { name: string; type: string; required: boolean }[];
82
82
  }
83
83
 
@@ -93,7 +93,7 @@ export interface GivenTypeInfo {
93
93
  column: number;
94
94
  ordinal: number; // Sequential position within the file
95
95
  typeName: string;
96
- classification: 'command' | 'event' | 'state';
96
+ classification: 'command' | 'event' | 'state' | 'query';
97
97
  }
98
98
 
99
99
  function extractDataFieldsFromTypeLiteral(
@@ -136,7 +136,7 @@ function processTypeAlias(
136
136
  const baseName = getBaseName(typeRef.typeName);
137
137
  if (typeof baseName !== 'string') return;
138
138
 
139
- if (!['Command', 'Event', 'State'].includes(baseName)) return;
139
+ if (!['Command', 'Event', 'State', 'Query'].includes(baseName)) return;
140
140
 
141
141
  const typeArgs = typeRef.typeArguments ?? [];
142
142
  if (typeArgs.length === 0) return;
@@ -145,7 +145,7 @@ function processTypeAlias(
145
145
  if (!ts.isLiteralTypeNode(firstArg) || !ts.isStringLiteral(firstArg.literal)) return;
146
146
 
147
147
  const stringLiteral = firstArg.literal.text;
148
- const classification = baseName.toLowerCase() as 'command' | 'event' | 'state';
148
+ const classification = baseName.toLowerCase() as 'command' | 'event' | 'state' | 'query';
149
149
 
150
150
  // Try to extract the data fields from the 2nd generic arg (if present)
151
151
  let dataFields: { name: string; type: string; required: boolean }[] | undefined;
@@ -271,7 +271,7 @@ function extractDataFields(
271
271
  return dataFields;
272
272
  }
273
273
 
274
- function inferClassificationFromName(stringLiteral: string): 'command' | 'event' | 'state' | undefined {
274
+ function inferClassificationFromName(stringLiteral: string): 'command' | 'event' | 'state' | 'query' | undefined {
275
275
  const eventPatterns = ['ed', 'Created', 'Updated', 'Deleted', 'Placed', 'Added', 'Removed', 'Changed'];
276
276
  if (eventPatterns.some((pattern) => stringLiteral.endsWith(pattern))) {
277
277
  return 'event';
@@ -282,6 +282,11 @@ function inferClassificationFromName(stringLiteral: string): 'command' | 'event'
282
282
  return 'command';
283
283
  }
284
284
 
285
+ // Query patterns: View*, Get*, List*, Find*, Search*, Fetch*
286
+ if (/^(View|Get|List|Find|Search|Fetch)[A-Z]/.test(stringLiteral)) {
287
+ return 'query';
288
+ }
289
+
285
290
  const statePatterns = ['Summary', 'View', 'Items', 'List', 'Data', 'Info'];
286
291
  if (statePatterns.some((pattern) => stringLiteral.endsWith(pattern))) {
287
292
  return 'state';
@@ -463,7 +468,7 @@ function classifyBaseGeneric(
463
468
  ts: typeof import('typescript'),
464
469
  checker: import('typescript').TypeChecker,
465
470
  typeRef: import('typescript').TypeReferenceNode,
466
- ): 'event' | 'command' | 'state' | null {
471
+ ): 'event' | 'command' | 'state' | 'query' | null {
467
472
  // Resolve base symbol (handles aliases and qualified names)
468
473
  let sym: import('typescript').Symbol | undefined;
469
474
  if (ts.isIdentifier(typeRef.typeName) || ts.isQualifiedName(typeRef.typeName)) {
@@ -476,6 +481,7 @@ function classifyBaseGeneric(
476
481
  if (base.endsWith('Event')) return 'event';
477
482
  if (base.endsWith('Command')) return 'command';
478
483
  if (base.endsWith('State')) return 'state';
484
+ if (base.endsWith('Query')) return 'query';
479
485
  return null;
480
486
  }
481
487
 
@@ -483,7 +489,7 @@ function tryUnwrapDirectGeneric(
483
489
  ts: typeof import('typescript'),
484
490
  typeArg: import('typescript').TypeReferenceNode,
485
491
  checker: import('typescript').TypeChecker,
486
- ): { typeName: string; classification: 'event' | 'command' | 'state' } | null {
492
+ ): { typeName: string; classification: 'event' | 'command' | 'state' | 'query' } | null {
487
493
  if (typeArg.typeArguments === undefined || typeArg.typeArguments.length === 0) return null;
488
494
 
489
495
  const kind = classifyBaseGeneric(ts, checker, typeArg);
@@ -504,7 +510,7 @@ function tryUnwrapTypeAlias(
504
510
  typeArg: import('typescript').TypeReferenceNode,
505
511
  typeMap: Map<string, TypeInfo>,
506
512
  typesByFile: Map<string, Map<string, TypeInfo>>,
507
- ): { typeName: string; classification: 'event' | 'command' | 'state' } | null {
513
+ ): { typeName: string; classification: 'event' | 'command' | 'state' | 'query' } | null {
508
514
  if (!ts.isIdentifier(typeArg.typeName)) return null;
509
515
 
510
516
  const typeName = typeArg.typeName.text;
@@ -526,7 +532,7 @@ function tryUnwrapGeneric(
526
532
  checker: import('typescript').TypeChecker,
527
533
  typeMap: Map<string, TypeInfo>,
528
534
  typesByFile: Map<string, Map<string, TypeInfo>>,
529
- ): { typeName: string; classification: 'event' | 'command' | 'state' } | null {
535
+ ): { typeName: string; classification: 'event' | 'command' | 'state' | 'query' } | null {
530
536
  if (!ts.isTypeReferenceNode(typeArg)) return null;
531
537
 
532
538
  return tryUnwrapDirectGeneric(ts, typeArg, checker) ?? tryUnwrapTypeAlias(ts, typeArg, typeMap, typesByFile);
@@ -554,7 +560,7 @@ function createGivenTypeInfo(
554
560
  node: import('typescript').CallExpression,
555
561
  ordinal: number,
556
562
  typeName: string,
557
- classification: 'event' | 'command' | 'state',
563
+ classification: 'event' | 'command' | 'state' | 'query',
558
564
  ): GivenTypeInfo {
559
565
  const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
560
566
  return {
@@ -658,6 +658,59 @@ narrative('Test Flow with Rule IDs', 'FLOW-456', () => {
658
658
  `);
659
659
  });
660
660
 
661
+ it('should correctly generate Query type alias for query messages', async () => {
662
+ const modelWithQueryMessage: Model = {
663
+ variant: 'specs',
664
+ narratives: [
665
+ {
666
+ name: 'Workout Flow',
667
+ id: 'FLOW-001',
668
+ slices: [],
669
+ },
670
+ ],
671
+ messages: [
672
+ {
673
+ type: 'query',
674
+ name: 'GetWorkoutHistory',
675
+ fields: [
676
+ { name: 'memberId', type: 'string', required: true },
677
+ { name: 'limit', type: 'number', required: false },
678
+ ],
679
+ metadata: { version: 1 },
680
+ },
681
+ {
682
+ type: 'event',
683
+ name: 'WorkoutRecorded',
684
+ fields: [{ name: 'workoutId', type: 'string', required: true }],
685
+ source: 'internal',
686
+ metadata: { version: 1 },
687
+ },
688
+ ],
689
+ integrations: [],
690
+ modules: [],
691
+ };
692
+
693
+ const code = getCode(await modelToNarrative(modelWithQueryMessage));
694
+
695
+ expect(code).toEqual(`import { narrative } from '@auto-engineer/narrative';
696
+ import type { Event, Query } from '@auto-engineer/narrative';
697
+ type GetWorkoutHistory = Query<
698
+ 'GetWorkoutHistory',
699
+ {
700
+ memberId: string;
701
+ limit?: number;
702
+ }
703
+ >;
704
+ type WorkoutRecorded = Event<
705
+ 'WorkoutRecorded',
706
+ {
707
+ workoutId: string;
708
+ }
709
+ >;
710
+ narrative('Workout Flow', 'FLOW-001', () => {});
711
+ `);
712
+ });
713
+
661
714
  it('should correctly resolve Date types in messages', async () => {
662
715
  const modelWithDateTypes: Model = {
663
716
  variant: 'specs',
package/src/schema.ts CHANGED
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  // Message reference for module type ownership
4
4
  export const MessageRefSchema = z
5
5
  .object({
6
- kind: z.enum(['command', 'event', 'state']).describe('Message kind'),
6
+ kind: z.enum(['command', 'event', 'state', 'query']).describe('Message kind'),
7
7
  name: z.string().describe('Message name'),
8
8
  })
9
9
  .describe('Reference to a message type');
@@ -174,7 +174,11 @@ const StateSchema = BaseMessageSchema.extend({
174
174
  type: z.literal('state'),
175
175
  }).describe('State/Read Model representing a view of data');
176
176
 
177
- const MessageSchema = z.discriminatedUnion('type', [CommandSchema, EventSchema, StateSchema]);
177
+ const QuerySchema = BaseMessageSchema.extend({
178
+ type: z.literal('query'),
179
+ }).describe('Query representing a read operation');
180
+
181
+ const MessageSchema = z.discriminatedUnion('type', [CommandSchema, EventSchema, StateSchema, QuerySchema]);
178
182
 
179
183
  const BaseSliceSchema = z
180
184
  .object({
@@ -417,6 +421,7 @@ export {
417
421
  CommandSchema,
418
422
  EventSchema,
419
423
  StateSchema,
424
+ QuerySchema,
420
425
  IntegrationSchema,
421
426
  CommandSliceSchema,
422
427
  QuerySliceSchema,
@@ -35,6 +35,7 @@ export function buildImports(
35
35
  command: 'Command',
36
36
  event: 'Event',
37
37
  state: 'State',
38
+ query: 'Query',
38
39
  };
39
40
 
40
41
  const flowTypeNames = Array.from(usedMessageTypes)
@@ -2,7 +2,7 @@ import type tsNS from 'typescript';
2
2
  import { typeFromString } from '../ast/emit-helpers';
3
3
 
4
4
  type Message = {
5
- type: 'command' | 'event' | 'state';
5
+ type: 'command' | 'event' | 'state' | 'query';
6
6
  name: string;
7
7
  fields: { name: string; type: string; required: boolean }[];
8
8
  };
@@ -33,10 +33,9 @@ export function buildTypeAliases(ts: typeof tsNS, messages: Message[]): tsNS.Sta
33
33
 
34
34
  const name = f.createIdentifier(m.name);
35
35
 
36
- const rhs = f.createTypeReferenceNode(
37
- m.type === 'event' ? 'Event' : m.type === 'command' ? 'Command' : 'State',
38
- typeArgs,
39
- );
36
+ const baseTypeName =
37
+ m.type === 'event' ? 'Event' : m.type === 'command' ? 'Command' : m.type === 'query' ? 'Query' : 'State';
38
+ const rhs = f.createTypeReferenceNode(baseTypeName, typeArgs);
40
39
 
41
40
  return f.createTypeAliasDeclaration(
42
41
  undefined, // No export keyword
@@ -12,7 +12,7 @@ import { resolveInferredType } from './type-inference';
12
12
 
13
13
  type TypeResolver = (
14
14
  t: string,
15
- expected?: 'command' | 'event' | 'state',
15
+ expected?: 'command' | 'event' | 'state' | 'query',
16
16
  exampleData?: unknown,
17
17
  ) => { resolvedName: string; typeInfo: TypeInfo | undefined };
18
18
 
@@ -47,7 +47,7 @@ function getTypesForNarrative(
47
47
  function tryResolveFromNarrativeTypes(
48
48
  t: string,
49
49
  narrativeSpecificTypes: Map<string, TypeInfo>,
50
- expected?: 'command' | 'event' | 'state',
50
+ expected?: 'command' | 'event' | 'state' | 'query',
51
51
  exampleData?: unknown,
52
52
  ): { resolvedName: string; typeInfo: TypeInfo | undefined } {
53
53
  if (t !== 'InferredType') {
@@ -68,7 +68,7 @@ function tryFallbackToUnionTypes(
68
68
  resolvedName: string,
69
69
  typeInfo: TypeInfo | undefined,
70
70
  unionTypes: Map<string, TypeInfo>,
71
- expected?: 'command' | 'event' | 'state',
71
+ expected?: 'command' | 'event' | 'state' | 'query',
72
72
  exampleData?: unknown,
73
73
  ): { resolvedName: string; typeInfo: TypeInfo | undefined } {
74
74
  if (resolvedName !== 'InferredType' && typeInfo) {
@@ -88,7 +88,7 @@ function tryFallbackToUnionTypes(
88
88
  function tryResolveFromUnionTypes(
89
89
  t: string,
90
90
  unionTypes: Map<string, TypeInfo>,
91
- expected?: 'command' | 'event' | 'state',
91
+ expected?: 'command' | 'event' | 'state' | 'query',
92
92
  exampleData?: unknown,
93
93
  ): { resolvedName: string; typeInfo: TypeInfo | undefined } {
94
94
  if (t !== 'InferredType') {
@@ -110,7 +110,7 @@ function createTypeResolver(
110
110
  ) {
111
111
  return (
112
112
  t: string,
113
- expected?: 'command' | 'event' | 'state',
113
+ expected?: 'command' | 'event' | 'state' | 'query',
114
114
  exampleData?: unknown,
115
115
  ): { resolvedName: string; typeInfo: TypeInfo | undefined } => {
116
116
  if (narrativeSpecificTypes) {
@@ -1,9 +1,9 @@
1
1
  import type { Message } from '../../index';
2
2
  import type { TypeInfo } from '../../loader/ts-utils';
3
3
 
4
- function mapKindToMessageType(k: 'command' | 'query' | 'reaction'): 'command' | 'event' | 'state' {
4
+ function mapKindToMessageType(k: 'command' | 'query' | 'reaction'): 'command' | 'event' | 'state' | 'query' {
5
5
  if (k === 'command') return 'command';
6
- if (k === 'query') return 'state';
6
+ if (k === 'query') return 'query';
7
7
  return 'event';
8
8
  }
9
9
 
@@ -52,7 +52,7 @@ function processStateFields(
52
52
  export function createMessage(
53
53
  name: string,
54
54
  typeInfo: TypeInfo | undefined,
55
- messageType: 'command' | 'event' | 'state',
55
+ messageType: 'command' | 'event' | 'state' | 'query',
56
56
  ): Message {
57
57
  let fields = buildInitialFields(typeInfo);
58
58
 
@@ -81,6 +81,15 @@ export function createMessage(
81
81
  };
82
82
  }
83
83
 
84
+ if (messageType === 'query') {
85
+ return {
86
+ type: 'query',
87
+ name,
88
+ fields,
89
+ metadata,
90
+ };
91
+ }
92
+
84
93
  return {
85
94
  type: 'state',
86
95
  name,
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Message } from '../../index';
3
+ import type { ExampleShapeHints } from './example-shapes';
4
+ import { detectQueryAction, extractQueryNameFromRequest, processWhen } from './spec-processors';
5
+
6
+ describe('spec-processors', () => {
7
+ describe('extractQueryNameFromRequest', () => {
8
+ it('should extract query name from simple GraphQL query', () => {
9
+ const request = 'query ViewWorkoutPlan { workoutPlan { id } }';
10
+ expect(extractQueryNameFromRequest(request)).toBe('ViewWorkoutPlan');
11
+ });
12
+
13
+ it('should extract query name from GraphQL query with variables', () => {
14
+ const request = 'query ViewWorkoutPlan($workoutId: ID!) { workoutPlan(id: $workoutId) { id name } }';
15
+ expect(extractQueryNameFromRequest(request)).toBe('ViewWorkoutPlan');
16
+ });
17
+
18
+ it('should extract query name case-insensitively', () => {
19
+ const request = 'QUERY GetUserProfile { user { id } }';
20
+ expect(extractQueryNameFromRequest(request)).toBe('GetUserProfile');
21
+ });
22
+
23
+ it('should return null for undefined request', () => {
24
+ expect(extractQueryNameFromRequest(undefined)).toBe(null);
25
+ });
26
+
27
+ it('should return null for empty string', () => {
28
+ expect(extractQueryNameFromRequest('')).toBe(null);
29
+ });
30
+
31
+ it('should return null for mutation', () => {
32
+ const request = 'mutation CreateWorkout { createWorkout { id } }';
33
+ expect(extractQueryNameFromRequest(request)).toBe(null);
34
+ });
35
+
36
+ it('should extract query name from JSON AST format', () => {
37
+ const ast = {
38
+ kind: 'Document',
39
+ definitions: [
40
+ {
41
+ kind: 'OperationDefinition',
42
+ operation: 'query',
43
+ name: { value: 'ListWorkouts' },
44
+ },
45
+ ],
46
+ };
47
+ expect(extractQueryNameFromRequest(JSON.stringify(ast))).toBe('ListWorkouts');
48
+ });
49
+ });
50
+
51
+ describe('detectQueryAction', () => {
52
+ it('should return true when whenText exactly matches query name from request', () => {
53
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan($id: ID!) { workoutPlan { id } }' };
54
+ expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(true);
55
+ });
56
+
57
+ it('should return true when whenText matches query name case-insensitively', () => {
58
+ const slice = { type: 'query', request: 'query viewWorkoutPlan { workoutPlan { id } }' };
59
+ expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(true);
60
+ });
61
+
62
+ it('should return false for command slices', () => {
63
+ const slice = { type: 'command', request: 'mutation CreateWorkout { createWorkout { id } }' };
64
+ expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(false);
65
+ });
66
+
67
+ it('should return false when no request field is provided (no naming convention fallback)', () => {
68
+ const slice = { type: 'query' };
69
+ // Without request field, cannot determine if it's a query action
70
+ expect(detectQueryAction('ViewWorkoutPlan', slice)).toBe(false);
71
+ expect(detectQueryAction('GetUserProfile', slice)).toBe(false);
72
+ expect(detectQueryAction('ListWorkouts', slice)).toBe(false);
73
+ expect(detectQueryAction('FindUserById', slice)).toBe(false);
74
+ expect(detectQueryAction('SearchProducts', slice)).toBe(false);
75
+ expect(detectQueryAction('FetchOrders', slice)).toBe(false);
76
+ });
77
+
78
+ it('should return false for event names in query slices', () => {
79
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
80
+ expect(detectQueryAction('WorkoutPlanCreated', slice)).toBe(false);
81
+ });
82
+
83
+ it('should return true for case-insensitive match to query name from request', () => {
84
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
85
+ // Case-insensitive matching works when request field is present
86
+ expect(detectQueryAction('viewWorkoutPlan', slice)).toBe(true);
87
+ });
88
+
89
+ it('should return false for empty whenText', () => {
90
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
91
+ expect(detectQueryAction('', slice)).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('processWhen', () => {
96
+ const mockTypeResolver = (t: string) => ({
97
+ resolvedName: t,
98
+ typeInfo: undefined,
99
+ });
100
+
101
+ const createEmptyHints = (): ExampleShapeHints => new Map();
102
+
103
+ describe('query action detection', () => {
104
+ it('should create a query message when When is a query action matching request', () => {
105
+ const messages: Map<string, Message> = new Map();
106
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan($id: ID!) { workoutPlan { id } }' };
107
+ const when = { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } };
108
+
109
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
110
+
111
+ // Query message should be created for query actions
112
+ expect(messages.size).toBe(1);
113
+ expect(messages.has('ViewWorkoutPlan')).toBe(true);
114
+ expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
115
+ });
116
+
117
+ it('should create a message when no request field (cannot detect query action)', () => {
118
+ const messages: Map<string, Message> = new Map();
119
+ const slice = { type: 'query' }; // No request field
120
+ const when = { commandRef: 'ViewWorkoutHistory', exampleData: { userId: 'user_456' } };
121
+
122
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
123
+
124
+ // Without request field, cannot detect query action - treats as event
125
+ expect(messages.size).toBe(1);
126
+ expect(messages.has('ViewWorkoutHistory')).toBe(true);
127
+ });
128
+
129
+ it('should create a query message when When matches query name from request', () => {
130
+ const messages: Map<string, Message> = new Map();
131
+ const slice = { type: 'query', request: 'query GetUserProfile { user { id } }' };
132
+ const when = { commandRef: 'GetUserProfile', exampleData: { userId: 'user_123' } };
133
+
134
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
135
+
136
+ // Query action detected via request field - query message created
137
+ expect(messages.size).toBe(1);
138
+ expect(messages.has('GetUserProfile')).toBe(true);
139
+ expect(messages.get('GetUserProfile')?.type).toBe('query');
140
+ });
141
+
142
+ it('should create a message when When is an event name in query slice', () => {
143
+ const messages: Map<string, Message> = new Map();
144
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
145
+ const when = { commandRef: 'WorkoutPlanUpdated', exampleData: { workoutId: 'wrk_123' } };
146
+
147
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
148
+
149
+ // Event messages SHOULD be created
150
+ expect(messages.size).toBe(1);
151
+ expect(messages.has('WorkoutPlanUpdated')).toBe(true);
152
+ });
153
+
154
+ it('should create a message for command slices regardless of naming', () => {
155
+ const messages: Map<string, Message> = new Map();
156
+ const slice = { type: 'command' };
157
+ const when = { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } };
158
+
159
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
160
+
161
+ // Command messages SHOULD be created even if it looks like a query action name
162
+ expect(messages.size).toBe(1);
163
+ expect(messages.has('ViewWorkoutPlan')).toBe(true);
164
+ });
165
+ });
166
+
167
+ describe('array of When items', () => {
168
+ it('should create query messages for query action items matching request', () => {
169
+ const messages: Map<string, Message> = new Map();
170
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
171
+ const when = [{ commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } }];
172
+
173
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
174
+
175
+ // Query action matches request - query message created
176
+ expect(messages.size).toBe(1);
177
+ expect(messages.has('ViewWorkoutPlan')).toBe(true);
178
+ expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
179
+ });
180
+
181
+ it('should create messages when no request field (cannot detect query action)', () => {
182
+ const messages: Map<string, Message> = new Map();
183
+ const slice = { type: 'query' }; // No request field
184
+ const when = [{ commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } }];
185
+
186
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
187
+
188
+ // Without request field, cannot detect query action - message created
189
+ expect(messages.size).toBe(1);
190
+ expect(messages.has('ViewWorkoutPlan')).toBe(true);
191
+ });
192
+
193
+ it('should create messages for event items in array', () => {
194
+ const messages: Map<string, Message> = new Map();
195
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
196
+ const when = [
197
+ { commandRef: 'WorkoutPlanCreated', exampleData: { workoutId: 'wrk_123' } },
198
+ { commandRef: 'WorkoutPlanUpdated', exampleData: { workoutId: 'wrk_123' } },
199
+ ];
200
+
201
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
202
+
203
+ expect(messages.size).toBe(2);
204
+ expect(messages.has('WorkoutPlanCreated')).toBe(true);
205
+ expect(messages.has('WorkoutPlanUpdated')).toBe(true);
206
+ });
207
+
208
+ it('should create query message for query action and event message in mixed array', () => {
209
+ const messages: Map<string, Message> = new Map();
210
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
211
+ const when = [
212
+ { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123' } }, // Query action - create query message
213
+ { commandRef: 'WorkoutPlanUpdated', exampleData: { workoutId: 'wrk_123' } }, // Event - create event message
214
+ ];
215
+
216
+ processWhen(when, slice, mockTypeResolver, messages, createEmptyHints());
217
+
218
+ expect(messages.size).toBe(2);
219
+ expect(messages.has('ViewWorkoutPlan')).toBe(true);
220
+ expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
221
+ expect(messages.has('WorkoutPlanUpdated')).toBe(true);
222
+ });
223
+ });
224
+
225
+ describe('example shape hints collection', () => {
226
+ it('should collect example hints for query actions', () => {
227
+ const messages: Map<string, Message> = new Map();
228
+ const slice = { type: 'query', request: 'query ViewWorkoutPlan { workoutPlan { id } }' };
229
+ const when = { commandRef: 'ViewWorkoutPlan', exampleData: { workoutId: 'wrk_123', userId: 'user_456' } };
230
+ const hints = createEmptyHints();
231
+
232
+ processWhen(when, slice, mockTypeResolver, messages, hints);
233
+
234
+ // Query message created and hints should be collected
235
+ expect(messages.size).toBe(1);
236
+ expect(messages.get('ViewWorkoutPlan')?.type).toBe('query');
237
+ expect(hints.has('ViewWorkoutPlan')).toBe(true);
238
+ });
239
+ });
240
+ });
241
+ });