Skip to content

Commit 4406d25

Browse files
committed
feat: add tests for circular and conditional parameter dependencies in schema
1 parent 496b768 commit 4406d25

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

test/schema/circular.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {describe, expect, it} from "vitest";
2+
import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types";
3+
import {getSignatureSchema} from "../../src";
4+
import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data";
5+
6+
// A single two-parameter node calling `identifier`, probed at that node. Returns
7+
// the input kind and blockedBy list for each of the two parameters.
8+
const probeTwoParam = (
9+
identifier: string,
10+
extraDataTypes: DataType[],
11+
extraFunctions: FunctionDefinition[],
12+
v0: any,
13+
v1: any,
14+
) => {
15+
const flow: Flow = {
16+
id: "gid://sagittarius/Flow/1",
17+
startingNodeId: "gid://sagittarius/NodeFunction/1",
18+
signature: "(): void",
19+
nodes: {
20+
nodes: [
21+
{
22+
id: "gid://sagittarius/NodeFunction/1",
23+
functionDefinition: {identifier},
24+
parameters: {nodes: [{value: v0}, {value: v1}]},
25+
},
26+
],
27+
},
28+
};
29+
return getSignatureSchema(
30+
flow,
31+
[...DATA_TYPES, ...extraDataTypes],
32+
[...FUNCTION_SIGNATURES, ...extraFunctions],
33+
"gid://sagittarius/NodeFunction/1",
34+
).parameters.map((p) => ({input: p.schema.input, blockedBy: p.blockedBy}));
35+
};
36+
37+
const lit = (value: any) => ({__typename: "LiteralValue", value});
38+
39+
describe("Schema — circular / conditional parameter dependencies", () => {
40+
41+
// ─────────────────────────────────────────────────────────────────────────
42+
// Scenario A: distinct generics, each used in exactly ONE parameter.
43+
// <L, R>(left: CIRC_LEFT<R>, right: CIRC_RIGHT<L>)
44+
// The two parameters mutually depend on each other, but because no single
45+
// generic is shared across both parameters, the shared-generic detector never
46+
// records a dependency. Neither is ever blocked.
47+
// ─────────────────────────────────────────────────────────────────────────
48+
describe("distinct generics, one per parameter (dependency invisible)", () => {
49+
const dataTypes: DataType[] = [
50+
{identifier: "CIRC_LEFT", genericKeys: ["T"], type: "T extends 'json' ? OBJECT<{}> : string"},
51+
{identifier: "CIRC_RIGHT", genericKeys: ["T"], type: "T extends string ? 'json' : 'text'"},
52+
];
53+
const fn: FunctionDefinition = {
54+
id: "gid://sagittarius/FunctionDefinition/9100",
55+
identifier: "custom::test::circular_distinct",
56+
signature: "<L, R>(left: CIRC_LEFT<R>, right: CIRC_RIGHT<L>): void",
57+
};
58+
const probe = (v0: any, v1: any) =>
59+
probeTwoParam("custom::test::circular_distinct", dataTypes, [fn], v0, v1);
60+
61+
it("never blocks either parameter, for any combination of values", () => {
62+
for (const [l, r] of [
63+
[null, null],
64+
[lit({}), null],
65+
[null, lit("json")],
66+
[lit({}), lit("json")],
67+
] as const) {
68+
const [left, right] = probe(l, r);
69+
expect(left.blockedBy).toEqual([]);
70+
expect(right.blockedBy).toEqual([]);
71+
}
72+
});
73+
74+
it("resolves each conditional to its unbound-generic (false) branch → both text", () => {
75+
// CIRC_LEFT<R> → R extends 'json' ? OBJECT<{}> : string → string → "text"
76+
// CIRC_RIGHT<L> → L extends string ? 'json' : 'text' → 'text' → "text"
77+
for (const [l, r] of [
78+
[null, null],
79+
[lit({}), lit("json")],
80+
] as const) {
81+
const [left, right] = probe(l, r);
82+
expect(left.input).toBe("text");
83+
expect(right.input).toBe("text");
84+
}
85+
});
86+
});
87+
88+
// ─────────────────────────────────────────────────────────────────────────
89+
// Scenario B: both generics shared across both parameters, conditional falls
90+
// back to a CONCRETE type.
91+
// <L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>)
92+
// A dependency IS detected (right depends on left), but the second check drops
93+
// it because `right`'s conditional always lands on a concrete branch — so it
94+
// never looks unresolved. Nothing is blocked.
95+
// ─────────────────────────────────────────────────────────────────────────
96+
describe("shared generics, conditional with concrete fallback (never blocked)", () => {
97+
const dataTypes: DataType[] = [
98+
{identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R extends string ? string : number"},
99+
{identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : L extends string ? string : number"},
100+
];
101+
const fn: FunctionDefinition = {
102+
id: "gid://sagittarius/FunctionDefinition/9101",
103+
identifier: "custom::test::circular_concrete",
104+
signature: "<L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>): void",
105+
};
106+
const probe = (v0: any, v1: any) =>
107+
probeTwoParam("custom::test::circular_concrete", dataTypes, [fn], v0, v1);
108+
109+
it("never blocks either parameter, because the dependent always has a concrete type", () => {
110+
for (const [l, r] of [
111+
[null, null],
112+
[lit({}), null],
113+
[null, lit({})],
114+
[lit({}), lit({})],
115+
] as const) {
116+
const [left, right] = probe(l, r);
117+
expect(left.blockedBy).toEqual([]);
118+
expect(right.blockedBy).toEqual([]);
119+
// The unbound conditionals collapse to their innermost `number` branch.
120+
expect(left.input).toBe("number");
121+
expect(right.input).toBe("number");
122+
}
123+
});
124+
});
125+
126+
// ─────────────────────────────────────────────────────────────────────────
127+
// Scenario C: both generics shared across both parameters, conditional falls
128+
// back to a BARE generic (so nothing resolves until the other side binds it).
129+
// <L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>)
130+
// Now the dependency both (1) is detected and (2) survives the concrete-type
131+
// check, so `right` is genuinely blocked by `left`.
132+
// ─────────────────────────────────────────────────────────────────────────
133+
describe("shared generics, bare-generic fallback (right blocked by left)", () => {
134+
const dataTypes: DataType[] = [
135+
{identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R"},
136+
{identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : L"},
137+
];
138+
const fn: FunctionDefinition = {
139+
id: "gid://sagittarius/FunctionDefinition/9102",
140+
identifier: "custom::test::circular_bare",
141+
signature: "<L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>): void",
142+
};
143+
const probe = (v0: any, v1: any) =>
144+
probeTwoParam("custom::test::circular_bare", dataTypes, [fn], v0, v1);
145+
146+
it("blocks right by left while nothing is set; left (index 0) is never blocked", () => {
147+
const [left, right] = probe(null, null);
148+
// left is the FIRST user of the shared generics → treated as the source,
149+
// never blocked.
150+
expect(left.blockedBy).toEqual([]);
151+
expect(left.input).toBe("generic");
152+
// right depends on left. Both L and R produce the same dependency, so the
153+
// index appears twice (the mechanism does not de-duplicate).
154+
expect(right.blockedBy).toEqual([0, 0]);
155+
expect(right.input).toBe("generic");
156+
});
157+
158+
it("unblocks right once left is provided", () => {
159+
const [left, right] = probe(lit("json"), null);
160+
expect(left.blockedBy).toEqual([]);
161+
expect(right.blockedBy).toEqual([]);
162+
});
163+
164+
it("never blocks left, whichever side is set", () => {
165+
for (const [l, r] of [
166+
[null, null],
167+
[lit("json"), null],
168+
[null, lit("json")],
169+
[lit("json"), lit("json")],
170+
] as const) {
171+
expect(probe(l, r)[0].blockedBy).toEqual([]);
172+
}
173+
});
174+
});
175+
176+
// ─────────────────────────────────────────────────────────────────────────
177+
// Scenario D: asymmetric — the dependent's generic sits only in the CHECK
178+
// position of its own conditional, so it can never be inferred from the
179+
// dependent's own value.
180+
// CIRC_LEFT<L,R> = L extends 'json' ? OBJECT<{}> : R (R inferable here)
181+
// CIRC_RIGHT<L,R> = R extends 'json' ? OBJECT<{}> : undefined (R only in check pos.)
182+
// `right` can only be unblocked by `left` (which binds R); filling `right`'s
183+
// own value does NOT unblock it.
184+
// ─────────────────────────────────────────────────────────────────────────
185+
describe("check-position generic (right unblockable only by left)", () => {
186+
const dataTypes: DataType[] = [
187+
{identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R"},
188+
{identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : undefined"},
189+
];
190+
const fn: FunctionDefinition = {
191+
id: "gid://sagittarius/FunctionDefinition/9103",
192+
identifier: "custom::test::circular_check",
193+
signature: "<L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>): void",
194+
};
195+
const probe = (v0: any, v1: any) =>
196+
probeTwoParam("custom::test::circular_check", dataTypes, [fn], v0, v1);
197+
198+
it("blocks right while left is empty", () => {
199+
const [left, right] = probe(null, null);
200+
expect(left.blockedBy).toEqual([]);
201+
expect(right.blockedBy).toEqual([0, 0]);
202+
});
203+
204+
it("does NOT unblock right when only right's own value is set", () => {
205+
// right's generic R is only in the check position of its conditional, so
206+
// TypeScript cannot infer R from right's value → right stays blocked on
207+
// left. The input shows "data" merely because a value was supplied (the
208+
// schema falls back to an open object for the UI), even though the block
209+
// remains.
210+
const [left, right] = probe(null, lit("json"));
211+
expect(left.blockedBy).toEqual([]);
212+
expect(right.blockedBy).toEqual([0, 0]);
213+
expect(right.input).toBe("data");
214+
});
215+
216+
it("unblocks right when left is set (the only thing that binds R)", () => {
217+
const [leftOnly] = [probe(lit("json"), null)];
218+
expect(leftOnly[1].blockedBy).toEqual([]);
219+
220+
const bothSet = probe(lit("json"), lit("json"));
221+
expect(bothSet[1].blockedBy).toEqual([]);
222+
});
223+
224+
it("never blocks left", () => {
225+
for (const [l, r] of [
226+
[null, null],
227+
[lit("json"), null],
228+
[null, lit("json")],
229+
[lit("json"), lit("json")],
230+
] as const) {
231+
expect(probe(l, r)[0].blockedBy).toEqual([]);
232+
}
233+
});
234+
});
235+
});

0 commit comments

Comments
 (0)