fix: strip the enum null sentinel, and make the preset test cover its wiring

Copilot round 4: no new visible comments, two in the suppressed block. Both
valid.

1. The `type: [T, "null"]` encoding keeps its keywords at the top level, so a
   nullable enum written that way carries the null *inside* the list:
   `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. The
   collapse left it there, which broke both renderers in different ways — the
   web dispatcher handed `null` to Mantine as option data, and the TUI's
   all-strings check rejected the whole enum and fell back to a plain text
   field. Neither is the dropdown the schema asks for.

   The collapse now strips a `null` member whenever it flattens, since that
   fact has already moved onto `nullable`. An enum of nothing but `null`
   drops out entirely: it offers no value a dropdown could show, so the plain
   widget serves the field better than an empty select. Covered in core and
   end-to-end in both dispatchers.

2. The new integration test claimed in its own header to pin the registry and
   config wiring, then called `createNullableFieldsTool()` directly — so a
   misspelt preset case or a config naming a dead preset would have left it
   green. A fair "this does not test what it says" catch. It now resolves the
   checked-in `nullable-fields-http.json` through `loadConfig` →
   `resolveConfig`, matching `duplicate-tool-names.test.ts`, and asserts the
   resolved tool names before booting the server.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:28:38 -04:00
commit 8356033d58

View file

@ -246,6 +246,64 @@ describe("normalizeNullableUnion", () => {
});
});
// The `type: [T, "null"]` encoding keeps its keywords at the top level, so a
// nullable enum written that way carries the null *inside* the list. Leaving
// it there hands `null` to Mantine as option data, and makes the TUI's
// all-strings check reject the enum and fall back to a text field.
it("strips the null sentinel from a type-array nullable enum", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["envio", "recebimento", null],
}),
).toEqual({
type: "string",
enum: ["envio", "recebimento"],
anyOf: undefined,
nullable: true,
});
});
it("strips a null sentinel hoisted off an anyOf branch too", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "string", enum: ["a", null] }, { type: "null" }],
}),
).toEqual({
type: "string",
enum: ["a"],
anyOf: undefined,
nullable: true,
});
});
it("drops an enum that held nothing but null", () => {
// No value a dropdown could offer, so the field is better served by its
// plain widget than by an empty select.
expect(
normalizeNullableUnion({ type: ["string", "null"], enum: [null] }),
).toEqual({
type: "string",
enum: undefined,
anyOf: undefined,
nullable: true,
});
});
it("leaves a null-free enum untouched", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["a", "b"],
}),
).toEqual({
type: "string",
enum: ["a", "b"],
anyOf: undefined,
nullable: true,
});
});
it("normalizes type array array|null", () => {
expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({
type: "array",