diff --git a/.chronus/changes/sramsey-csharp-server-union-failure-2026-5-17-14-5-13.md b/.chronus/changes/sramsey-csharp-server-union-failure-2026-5-17-14-5-13.md new file mode 100644 index 00000000000..9619c70081f --- /dev/null +++ b/.chronus/changes/sramsey-csharp-server-union-failure-2026-5-17-14-5-13.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Update the union definition to include unnamed string literals and null \ No newline at end of file diff --git a/packages/http-server-csharp/src/components/enums/enums.test.tsx b/packages/http-server-csharp/src/components/enums/enums.test.tsx index e0583855c93..811d2cb2c2e 100644 --- a/packages/http-server-csharp/src/components/enums/enums.test.tsx +++ b/packages/http-server-csharp/src/components/enums/enums.test.tsx @@ -1,10 +1,18 @@ import { Tester } from "#test/tester.js"; import { type Children } from "@alloy-js/core"; -import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp"; +import { + Attribute, + createCSharpNamePolicy, + EnumDeclaration as CsEnumDeclaration, + EnumMember, + SourceFile, +} from "@alloy-js/csharp"; +import { type Union } from "@typespec/compiler"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { Output } from "@typespec/emitter-framework"; import { EnumDeclaration } from "@typespec/emitter-framework/csharp"; import { beforeEach, describe, expect, it } from "vitest"; +import { getUnionEnumMembers, isUnionEnum } from "./enums.jsx"; let runner: TesterInstance; @@ -21,6 +29,26 @@ function Wrapper(props: { children: Children }) { ); } +/** + * Renders a union-as-enum using the same logic as the Enums component, + * but without the file/namespace/useTsp wrapping. + */ +function UnionEnumDecl(props: { union: Union }) { + const members = getUnionEnumMembers(props.union); + return ( + + {members.map((member, i) => ( + <> + + {"\n"} + + {i < members.length - 1 ? ",\n" : ""} + + ))} + + ); +} + describe("EnumDeclaration", () => { it("renders a simple enum", async () => { const { Color } = await runner.compile(t.code` @@ -97,3 +125,188 @@ describe("EnumDeclaration", () => { `); }); }); + +describe("isUnionEnum", () => { + it("returns true for extensible union with string base and named variants", async () => { + const { ReasoningEffort } = await runner.compile(t.code` + union ${t.union("ReasoningEffort")} { + string, + none: "none", + low: "low", + medium: "medium", + high: "high", + } + `); + + expect(isUnionEnum(ReasoningEffort)).toBe(true); + }); + + it("returns true for fixed union with named variants only", async () => { + const { Priority } = await runner.compile(t.code` + union ${t.union("Priority")} { + low: "low", + medium: "medium", + high: "high", + } + `); + + expect(isUnionEnum(Priority)).toBe(true); + }); + + it("returns false for union with non-string variant types", async () => { + const { Mixed } = await runner.compile(t.code` + model Foo { x: string; } + union ${t.union("Mixed")} { + Foo, + "bar", + } + `); + + expect(isUnionEnum(Mixed)).toBe(false); + }); +}); + +describe("union-as-enum rendering", () => { + it("renders union with unnamed string literals", async () => { + const { Priority } = await runner.compile(t.code` + union ${t.union("Priority")} { + "low", + "medium", + "high", + } + `); + + expect( + + + , + ).toRenderTo(` + public enum Priority + { + [JsonStringEnumMemberName("low")] + Low, + [JsonStringEnumMemberName("medium")] + Medium, + [JsonStringEnumMemberName("high")] + High + } + `); + }); + + it("renders union with unnamed string literals and null (null is skipped)", async () => { + const { ReasoningEffort } = await runner.compile(t.code` + union ${t.union("ReasoningEffort")} { + "none", + "minimal", + "low", + "medium", + "high", + null, + } + `); + + expect( + + + , + ).toRenderTo(` + public enum ReasoningEffort + { + [JsonStringEnumMemberName("none")] + None, + [JsonStringEnumMemberName("minimal")] + Minimal, + [JsonStringEnumMemberName("low")] + Low, + [JsonStringEnumMemberName("medium")] + Medium, + [JsonStringEnumMemberName("high")] + High + } + `); + }); + + it("renders union with named variants and null (null is skipped)", async () => { + const { ReasoningEffort } = await runner.compile(t.code` + union ${t.union("ReasoningEffort")} { + none: "none", + medium: "medium", + high: "high", + null, + } + `); + + expect( + + + , + ).toRenderTo(` + public enum ReasoningEffort + { + [JsonStringEnumMemberName("none")] + None, + [JsonStringEnumMemberName("medium")] + Medium, + [JsonStringEnumMemberName("high")] + High + } + `); + }); + + it("renders extensible union with string base, named variants, and null", async () => { + const { ReasoningEffort } = await runner.compile(t.code` + union ${t.union("ReasoningEffort")} { + string, + none: "none", + medium: "medium", + high: "high", + null, + } + `); + + expect( + + + , + ).toRenderTo(` + public enum ReasoningEffort + { + [JsonStringEnumMemberName("none")] + None, + [JsonStringEnumMemberName("medium")] + Medium, + [JsonStringEnumMemberName("high")] + High + } + `); + }); + + it("renders union with inline anonymous union of string literals and null", async () => { + const { ReasoningEffort } = await runner.compile(t.code` + union ${t.union("ReasoningEffort")} { + "none" | "minimal" | "low" | "medium" | "high", + null, + } + `); + + expect( + + + , + ).toRenderTo(` + public enum ReasoningEffort + { + [JsonStringEnumMemberName("none")] + None, + [JsonStringEnumMemberName("minimal")] + Minimal, + [JsonStringEnumMemberName("low")] + Low, + [JsonStringEnumMemberName("medium")] + Medium, + [JsonStringEnumMemberName("high")] + High + } + `); + }); +}); diff --git a/packages/http-server-csharp/src/components/enums/enums.tsx b/packages/http-server-csharp/src/components/enums/enums.tsx index 9f3be70673b..8fdcf3d9fa7 100644 --- a/packages/http-server-csharp/src/components/enums/enums.tsx +++ b/packages/http-server-csharp/src/components/enums/enums.tsx @@ -133,35 +133,62 @@ export function Enums(props: EnumsProps): Children { ); } +/** + * Returns true if an anonymous inline union contains only string literal variants. + */ +function isInlineStringLiteralUnion(type: Type): boolean { + if (type.kind !== "Union" || type.name) return false; + for (const variant of type.variants.values()) { + if (variant.type.kind !== "String") return false; + } + return type.variants.size > 0; +} + /** * Returns true if a named union can be represented as a C# enum. * Requires: named union, every named variant has a string value, - * and optionally one unnamed scalar `string` variant (open/extensible). + * and optionally one unnamed scalar `string` variant (open/extensible) + * and/or a `null` variant. Also supports inline anonymous unions of + * string literals (e.g., `"a" | "b" | "c"` as a single variant). */ export function isUnionEnum(union: Union): boolean { if (!union.name) return false; const variants = Array.from(union.variants.values()); - let hasNamedStringVariant = false; + let hasStringVariant = false; for (const variant of variants) { // Allow a single open string scalar variant (extensible union) if (variant.type.kind === "Scalar" && variant.type.name === "string") { continue; } + // Allow null variant (nullable union) + if (variant.type.kind === "Intrinsic" && variant.type.name === "null") { + continue; + } // Named variant with a string literal value if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") { - hasNamedStringVariant = true; + hasStringVariant = true; + continue; + } + // Unnamed variant with a string literal value (e.g., union { "low", "medium", "high" }) + if (variant.type.kind === "String" && typeof variant.name === "symbol") { + hasStringVariant = true; + continue; + } + // Inline anonymous union of string literals (e.g., "a" | "b" | "c" as a single variant) + if (isInlineStringLiteralUnion(variant.type)) { + hasStringVariant = true; continue; } // Any other variant type means it's not a simple enum return false; } - return hasNamedStringVariant; + return hasStringVariant; } -/** Gets the named string variants of a union-as-enum (skipping the open `string` variant). */ +/** Gets the named string variants of a union-as-enum (skipping the open `string` and `null` variants). */ export function getUnionEnumMembers( union: Union, ): { name: string; value: string; variant: import("@typespec/compiler").UnionVariant }[] { @@ -172,7 +199,22 @@ export function getUnionEnumMembers( }[] = []; for (const variant of union.variants.values()) { if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") { + // Named variant with explicit key (e.g., none: "none") members.push({ name: variant.name, value: variant.type.value, variant }); + } else if (variant.type.kind === "String" && typeof variant.name === "symbol") { + // Unnamed string literal variant (e.g., "none") — derive name from the value + members.push({ name: variant.type.value, value: variant.type.value, variant }); + } else if (isInlineStringLiteralUnion(variant.type)) { + // Inline anonymous union of string literals — flatten into individual members + for (const innerVariant of (variant.type as Union).variants.values()) { + if (innerVariant.type.kind === "String") { + members.push({ + name: innerVariant.type.value, + value: innerVariant.type.value, + variant: innerVariant, + }); + } + } } } return members;