Skip to content

Commit dbaf546

Browse files
dmealingclaude
andcommitted
test(output-prompt-conformance): C# runner reproduces corpus (10/10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4404f76 commit dbaf546

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
using System.Text.Json;
2+
using MetaObjects.Render.Prompt;
3+
using MetaObjects.Render.Recover;
4+
using Xunit;
5+
using RecoverEngine = MetaObjects.Render.Recover.Recover;
6+
7+
namespace MetaObjects.Render.Tests;
8+
9+
/// <summary>
10+
/// Cross-language output-prompt-conformance corpus runner — FR-010 artifact-1 (output-format
11+
/// prompt fragment) byte-identity gate. Each fixture dir under fixtures/output-prompt-conformance/ holds:
12+
/// spec.json descriptor: { format, rootName, roundTrip?, fields:[{ name, kind, required,
13+
/// array?, example?, instruction?, enumValues?, enumDoc?, nested? }] }
14+
/// expected.guide.txt byte-exact reference render for the GUIDE style
15+
/// expected.inline.txt byte-exact reference render for the INLINE style
16+
/// expected.exampleOnly.txt byte-exact reference render for the EXAMPLE_ONLY style
17+
///
18+
/// The corpus (authored by the TS pilot) is the oracle — do NOT edit it. Each style render must be
19+
/// byte-equal to its expected file; roundTrip cases additionally feed the exampleOnly fragment back
20+
/// through the recover engine and assert no field is MALFORMED / LOST_*. Mirrors the TS runner
21+
/// (server/typescript/packages/render/test/output-prompt-conformance.test.ts) exactly.
22+
/// </summary>
23+
public class OutputPromptConformanceTests
24+
{
25+
private static string CorpusRoot()
26+
{
27+
string root = AppContext.BaseDirectory;
28+
while (!Directory.Exists(Path.Combine(root, "fixtures", "output-prompt-conformance")))
29+
{
30+
string? parent = Directory.GetParent(root)?.FullName;
31+
if (parent is null || parent == root)
32+
throw new InvalidOperationException("fixtures/output-prompt-conformance not found");
33+
root = parent;
34+
}
35+
return Path.Combine(root, "fixtures", "output-prompt-conformance");
36+
}
37+
38+
public static IEnumerable<object[]> Cases()
39+
{
40+
string corpus = CorpusRoot();
41+
foreach (string dir in Directory.GetDirectories(corpus).OrderBy(d => d, StringComparer.Ordinal))
42+
{
43+
if (!File.Exists(Path.Combine(dir, "spec.json"))) continue;
44+
yield return new object[] { Path.GetFileName(dir) };
45+
}
46+
}
47+
48+
[Fact]
49+
public void Discovers_all_output_prompt_conformance_cases()
50+
{
51+
// Count guard: a port silently skipping cases must fail CI rather than reduce coverage.
52+
// Mirrors the TS / Java / Python count guards.
53+
Assert.Equal(10, Cases().Count());
54+
}
55+
56+
private static readonly (string Key, PromptStyle Style)[] Styles =
57+
{
58+
("guide", PromptStyle.Guide),
59+
("inline", PromptStyle.Inline),
60+
("exampleOnly", PromptStyle.ExampleOnly),
61+
};
62+
63+
[Theory]
64+
[MemberData(nameof(Cases))]
65+
public void ReproducesCorpusBytes(string caseName)
66+
{
67+
string dir = Path.Combine(CorpusRoot(), caseName);
68+
69+
using JsonDocument specDoc = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "spec.json")));
70+
CaseSpec spec = ParseCaseSpec(specDoc.RootElement);
71+
OutputFormatSpec ofs = BuildOutputSpec(spec);
72+
73+
foreach (var (key, style) in Styles)
74+
{
75+
var overrides = new PromptOverrides(style, null, null);
76+
string actual = OutputFormatRenderer.Render(ofs, overrides);
77+
78+
// Read expected file as raw UTF-8 (no newline translation). The TS-authored files use
79+
// "\n" line endings; File.ReadAllText preserves bytes; Assert.Equal on strings is ordinal.
80+
string expected = File.ReadAllText(Path.Combine(dir, $"expected.{key}.txt"));
81+
Assert.Equal(expected, actual); // zero-drift, byte-exact
82+
83+
// determinism: identical across runs
84+
Assert.Equal(actual, OutputFormatRenderer.Render(ofs, overrides));
85+
}
86+
87+
if (spec.RoundTrip)
88+
{
89+
string exampleFragment = File.ReadAllText(Path.Combine(dir, "expected.exampleOnly.txt"));
90+
RecoverOutcome outcome = RecoverEngine.Run(exampleFragment, BuildRecoverSchema(spec));
91+
92+
// Skew guard: a field the renderer emitted must read back cleanly. Assert NO state is
93+
// MALFORMED or LOST_* (robust to DEFAULTED and to how a nested OBJECT-container path
94+
// classifies). Any such state means the renderer emitted an example recover cannot read.
95+
foreach (var (field, state) in outcome.Report.States())
96+
{
97+
bool bad = state is FieldRecovery.MALFORMED
98+
or FieldRecovery.LOST_REQUIRED
99+
or FieldRecovery.LOST_OPTIONAL;
100+
Assert.False(bad, $"{caseName}: field '{field}' read back as {state}");
101+
}
102+
}
103+
}
104+
105+
// ─── BuildOutputSpec / BuildRecoverSchema (mirror the TS runner) ──────────
106+
107+
private static OutputFormatSpec BuildOutputSpec(CaseSpec c)
108+
{
109+
var fields = c.Fields.Select(f => new PromptField(
110+
Name: f.Name,
111+
Kind: f.Kind,
112+
Required: f.Required,
113+
Array: f.Array,
114+
EnumValues: f.EnumValues,
115+
EnumDoc: f.EnumDoc,
116+
Example: f.Example,
117+
Instruction: f.Instruction,
118+
Nested: f.Nested is null ? null : BuildOutputSpec(f.Nested))).ToList();
119+
// Style is a placeholder; each render overrides it per style (matches TS).
120+
return new OutputFormatSpec(c.Format, c.RootName, PromptStyle.Guide, fields);
121+
}
122+
123+
private static RecoverSchema BuildRecoverSchema(CaseSpec c)
124+
{
125+
var fields = c.Fields.Select(f => f.Kind switch
126+
{
127+
FieldKind.Enum => FieldSpec.EnumField(f.Name, f.Required, f.EnumValues, null),
128+
FieldKind.Object => FieldSpec.Object(
129+
f.Name, f.Required, f.Array,
130+
f.Nested is null ? null! : BuildRecoverSchema(f.Nested)),
131+
_ => FieldSpec.Scalar(f.Name, f.Kind, f.Required),
132+
}).ToList();
133+
return new RecoverSchema(c.Format, c.RootName, fields);
134+
}
135+
136+
// ─── descriptor parsing (the spec.json shape) ────────────────────────────
137+
138+
private sealed record CaseSpec(
139+
Format Format,
140+
string RootName,
141+
bool RoundTrip,
142+
IReadOnlyList<CaseField> Fields);
143+
144+
private sealed record CaseField(
145+
string Name,
146+
FieldKind Kind,
147+
bool Required,
148+
bool Array,
149+
IReadOnlyList<string>? EnumValues,
150+
IReadOnlyDictionary<string, string>? EnumDoc,
151+
string? Example,
152+
string? Instruction,
153+
CaseSpec? Nested);
154+
155+
private static CaseSpec ParseCaseSpec(JsonElement n)
156+
{
157+
Format format = ParseFormat(n.GetProperty("format").GetString()!);
158+
string rootName = n.GetProperty("rootName").GetString()!;
159+
bool roundTrip = n.TryGetProperty("roundTrip", out JsonElement rt) && rt.GetBoolean();
160+
var fields = new List<CaseField>();
161+
foreach (JsonElement f in n.GetProperty("fields").EnumerateArray())
162+
fields.Add(ParseCaseField(f));
163+
return new CaseSpec(format, rootName, roundTrip, fields);
164+
}
165+
166+
private static CaseField ParseCaseField(JsonElement f)
167+
{
168+
string name = f.GetProperty("name").GetString()!;
169+
FieldKind kind = ParseFieldKind(f.GetProperty("kind").GetString()!);
170+
bool required = f.TryGetProperty("required", out JsonElement reqEl) && reqEl.GetBoolean();
171+
bool array = f.TryGetProperty("array", out JsonElement arrEl) && arrEl.GetBoolean();
172+
173+
IReadOnlyList<string>? enumValues = null;
174+
if (f.TryGetProperty("enumValues", out JsonElement ev) && ev.ValueKind == JsonValueKind.Array)
175+
enumValues = ev.EnumerateArray().Select(x => x.GetString()!).ToList();
176+
177+
IReadOnlyDictionary<string, string>? enumDoc = null;
178+
if (f.TryGetProperty("enumDoc", out JsonElement ed) && ed.ValueKind == JsonValueKind.Object)
179+
{
180+
var map = new Dictionary<string, string>(StringComparer.Ordinal);
181+
foreach (JsonProperty p in ed.EnumerateObject()) map[p.Name] = p.Value.GetString()!;
182+
enumDoc = map;
183+
}
184+
185+
string? example = f.TryGetProperty("example", out JsonElement exEl) && exEl.ValueKind != JsonValueKind.Null
186+
? exEl.GetString()
187+
: null;
188+
string? instruction = f.TryGetProperty("instruction", out JsonElement inEl) && inEl.ValueKind != JsonValueKind.Null
189+
? inEl.GetString()
190+
: null;
191+
192+
CaseSpec? nested = f.TryGetProperty("nested", out JsonElement nestEl) && nestEl.ValueKind == JsonValueKind.Object
193+
? ParseCaseSpec(nestEl)
194+
: null;
195+
196+
return new CaseField(name, kind, required, array, enumValues, enumDoc, example, instruction, nested);
197+
}
198+
199+
private static Format ParseFormat(string s) => s switch
200+
{
201+
"json" => Format.Json,
202+
"xml" => Format.Xml,
203+
_ => throw new ArgumentException($"Unknown format: {s}"),
204+
};
205+
206+
private static FieldKind ParseFieldKind(string s) => s switch
207+
{
208+
"STRING" => FieldKind.String,
209+
"INT" => FieldKind.Int,
210+
"LONG" => FieldKind.Long,
211+
"DOUBLE" => FieldKind.Double,
212+
"BOOLEAN" => FieldKind.Boolean,
213+
"ENUM" => FieldKind.Enum,
214+
"OBJECT" => FieldKind.Object,
215+
_ => throw new ArgumentException($"Unknown field kind: {s}"),
216+
};
217+
}

0 commit comments

Comments
 (0)