|
| 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