Skip to content

Commit 2b8e502

Browse files
dmealingclaude
andcommitted
test(csharp): YAML conformance runner over fixtures/yaml-conformance/
Parametric xUnit [Theory] runner mirroring the canonical-JSON conformance harness shape. Auto-discovers every directory under fixtures/yaml-conformance/ and runs each fixture through the pipeline (desugar → canonical parse → post-parse validation passes), comparing the emitted ERR_* code set against expected-errors.json or the canonical serialization against expected.json. - YamlConformanceTests.cs — fixture discovery + [Theory] runner over the shared corpus, plus a per-language ledger (yaml-conformance-expected-failures.json) for known-gap fixtures. - YamlDesugarTests.cs — focused unit tests pinning the desugar contract (fused-key, scalar body, [] suffix, sigil-free attrs, reserved-key staying bare, @-reserved-keyword error, nested children, BOM, coercion guard). - MetaObjects.Conformance.Tests.csproj — copies the new ledger to output. C# YAML ledger gates only error-yaml-coerced-bool-in-string as known-gap: C# v1 metadata vocabulary doesn't declare @column on field.string, so the D2 coercion guard has no schema to compare `column: TRUE` against. The other 5 fixtures pass natively (including the 3 v2-vocabulary happy-paths — the canonical parser accepts unknown attrs and re-emits them, so v2 attrs like @objectref / @storage round-trip cleanly). Test totals: 415 → 433 tests passing (+12 desugar unit, +6 conformance). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4957dc1 commit 2b8e502

4 files changed

Lines changed: 551 additions & 0 deletions

File tree

server/csharp/MetaObjects.Conformance.Tests/MetaObjects.Conformance.Tests.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,8 @@
1717
<None Include="conformance-expected-failures.json">
1818
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
1919
</None>
20+
<None Include="yaml-conformance-expected-failures.json">
21+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
22+
</None>
2023
</ItemGroup>
2124
</Project>
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
// YamlConformanceTests — parametric YAML conformance runner over fixtures/yaml-conformance/.
2+
//
3+
// C# port of:
4+
// server/typescript/packages/metadata/test/yaml-conformance.test.ts (reference)
5+
// server/python/tests/conformance/test_yaml_conformance.py (sibling)
6+
// server/java/metadata/src/test/java/com/metaobjects/conformance/YamlConformanceTest.java
7+
//
8+
// Per ADR-0006 D4. Each fixture under fixtures/yaml-conformance/ contains:
9+
// - input.yaml (required)
10+
// - expected.json (happy path — canonical serialization must match)
11+
// OR
12+
// - expected-errors.json (error path — emitted ERR_* code set must match)
13+
//
14+
// Adding a directory under fixtures/yaml-conformance/ auto-creates a parametric test.
15+
// The corpus is shared across every port; C# is one of four (TS, Python, Java, C#).
16+
//
17+
// Per-language ledger lives at yaml-conformance-expected-failures.json next to this file.
18+
// C# gates the v2-vocabulary-dependent coercion fixtures as known-gap (C# v1 metadata
19+
// vocabulary doesn't declare @column on field.string, so the YAML coercion guard has
20+
// no schema to compare against). Other fixtures (including the v2 happy-paths) pass
21+
// because the canonical parser accepts unknown attrs (open policy) and re-emits them.
22+
23+
using System.Text.Json;
24+
using MetaObjects.Loader;
25+
using MetaObjects.Meta;
26+
using Xunit;
27+
28+
namespace MetaObjects.Conformance.Tests;
29+
30+
/// <summary>
31+
/// A discovered YAML conformance fixture.
32+
/// </summary>
33+
public sealed record YamlFixture(
34+
string Name,
35+
string Dir,
36+
bool HasExpected,
37+
bool HasExpectedErrors)
38+
{
39+
public override string ToString() => Name;
40+
}
41+
42+
public class YamlConformanceTests
43+
{
44+
// -------------------------------------------------------------------------
45+
// Corpus discovery
46+
// -------------------------------------------------------------------------
47+
48+
/// <summary>
49+
/// Absolute path to <c>fixtures/yaml-conformance/</c>. Honors the
50+
/// <c>METAOBJECTS_YAML_CONFORMANCE_CORPUS</c> env var; otherwise walks up
51+
/// from the test binary's base directory.
52+
/// </summary>
53+
public static string CorpusPath { get; } = LocateYamlCorpus();
54+
55+
private static string LocateYamlCorpus()
56+
{
57+
var env = Environment.GetEnvironmentVariable("METAOBJECTS_YAML_CONFORMANCE_CORPUS");
58+
if (!string.IsNullOrEmpty(env))
59+
{
60+
if (!Directory.Exists(env))
61+
throw new DirectoryNotFoundException(
62+
$"METAOBJECTS_YAML_CONFORMANCE_CORPUS points to a non-existent directory: {env}");
63+
return env;
64+
}
65+
66+
var dir = new DirectoryInfo(AppContext.BaseDirectory);
67+
while (dir != null)
68+
{
69+
var candidate = System.IO.Path.Combine(dir.FullName, "fixtures", "yaml-conformance");
70+
if (Directory.Exists(candidate))
71+
return candidate;
72+
dir = dir.Parent;
73+
}
74+
75+
throw new DirectoryNotFoundException(
76+
"Could not locate fixtures/yaml-conformance/ by walking up from " +
77+
AppContext.BaseDirectory +
78+
". Set METAOBJECTS_YAML_CONFORMANCE_CORPUS to point directly to the corpus.");
79+
}
80+
81+
/// <summary>xUnit MemberData source — one entry per discovered fixture.</summary>
82+
public static IEnumerable<object[]> Fixtures() => DiscoverFixtures(CorpusPath)
83+
.Select(f => new object[] { f });
84+
85+
/// <summary>
86+
/// Discover every YAML fixture directory under <paramref name="corpusRoot"/>,
87+
/// sorted by name. Each must have <c>input.yaml</c> and exactly one of
88+
/// <c>expected.json</c> / <c>expected-errors.json</c>.
89+
/// </summary>
90+
public static IReadOnlyList<YamlFixture> DiscoverFixtures(string corpusRoot)
91+
{
92+
var entries = Directory.GetDirectories(corpusRoot)
93+
.Select(d => System.IO.Path.GetFileName(d))
94+
.Order(StringComparer.Ordinal)
95+
.ToList();
96+
97+
var fixtures = new List<YamlFixture>(entries.Count);
98+
foreach (var name in entries)
99+
{
100+
var dir = System.IO.Path.Combine(corpusRoot, name);
101+
bool hasExpected = File.Exists(System.IO.Path.Combine(dir, "expected.json"));
102+
bool hasErrors = File.Exists(System.IO.Path.Combine(dir, "expected-errors.json"));
103+
if (hasExpected && hasErrors)
104+
throw new InvalidOperationException(
105+
$"Fixture '{name}' has both expected.json and expected-errors.json — one only.");
106+
if (!hasExpected && !hasErrors)
107+
throw new InvalidOperationException(
108+
$"Fixture '{name}' has neither expected.json nor expected-errors.json.");
109+
if (!File.Exists(System.IO.Path.Combine(dir, "input.yaml")))
110+
throw new InvalidOperationException(
111+
$"Fixture '{name}' is missing input.yaml.");
112+
113+
fixtures.Add(new YamlFixture(name, dir, hasExpected, hasErrors));
114+
}
115+
return fixtures;
116+
}
117+
118+
// -------------------------------------------------------------------------
119+
// Per-language YAML ledger
120+
// -------------------------------------------------------------------------
121+
122+
private static readonly IReadOnlyList<string> Ledger = LoadLedger();
123+
124+
private static IReadOnlyList<string> LoadLedger()
125+
{
126+
var ledgerPath = System.IO.Path.Combine(
127+
AppContext.BaseDirectory, "yaml-conformance-expected-failures.json");
128+
129+
if (!File.Exists(ledgerPath))
130+
return Array.Empty<string>();
131+
132+
try
133+
{
134+
var raw = File.ReadAllText(ledgerPath);
135+
var parsed = JsonSerializer.Deserialize<JsonElement>(raw);
136+
if (parsed.TryGetProperty("fixtures", out var fixtures) &&
137+
fixtures.ValueKind == JsonValueKind.Array)
138+
{
139+
return fixtures.EnumerateArray()
140+
.Where(e => e.ValueKind == JsonValueKind.String)
141+
.Select(e => e.GetString()!)
142+
.ToList();
143+
}
144+
}
145+
catch
146+
{
147+
// Missing or malformed ledger → empty.
148+
}
149+
150+
return Array.Empty<string>();
151+
}
152+
153+
private static string Classify(bool passed, string name)
154+
{
155+
bool listed = Ledger.Contains(name, StringComparer.Ordinal);
156+
if (!passed) return listed ? "known-gap" : "fail";
157+
return listed ? "fixed-but-listed" : "pass";
158+
}
159+
160+
// -------------------------------------------------------------------------
161+
// The test
162+
// -------------------------------------------------------------------------
163+
164+
[Theory]
165+
[MemberData(nameof(Fixtures))]
166+
public void Conformance(YamlFixture fix)
167+
{
168+
var failures = new List<string>();
169+
RunChecks(fix, failures);
170+
bool passed = failures.Count == 0;
171+
172+
string status = Classify(passed, fix.Name);
173+
string detail = passed ? "(no failures)" : string.Join("; ", failures);
174+
175+
Assert.True(
176+
status is "pass" or "known-gap",
177+
$"{fix.Name} [{status}]: {detail}");
178+
}
179+
180+
// -------------------------------------------------------------------------
181+
// Per-fixture pipeline
182+
// -------------------------------------------------------------------------
183+
184+
private static void RunChecks(YamlFixture fix, List<string> failures)
185+
{
186+
string yaml;
187+
try
188+
{
189+
yaml = File.ReadAllText(System.IO.Path.Combine(fix.Dir, "input.yaml"));
190+
}
191+
catch (Exception ex)
192+
{
193+
failures.Add($"input.yaml read error: {ex.Message}");
194+
return;
195+
}
196+
197+
// Compose a registry mirroring the canonical conformance harness — every
198+
// YAML fixture uses the standard core-types registry.
199+
var registry = Provider.ComposeRegistry([CoreTypes.CoreTypesProvider]);
200+
var parseOpts = new ParseOptions(registry)
201+
{
202+
SourceName = "input.yaml",
203+
};
204+
205+
var codesSeen = new HashSet<string>(StringComparer.Ordinal);
206+
var orderedCodes = new List<string>();
207+
void AddCode(string c)
208+
{
209+
if (codesSeen.Add(c)) orderedCodes.Add(c);
210+
}
211+
212+
// 1. Desugar — collect every desugar diagnostic (multi-error path).
213+
YamlParseCollectingResult? desugared = null;
214+
try
215+
{
216+
desugared = ParserYaml.ParseYamlCollecting(yaml, parseOpts);
217+
}
218+
catch (ParseException ex)
219+
{
220+
AddCode(ex.Code.ToString());
221+
}
222+
223+
if (desugared is not null)
224+
{
225+
foreach (var err in desugared.Errors)
226+
{
227+
AddCode((err.Code ?? ErrorCode.ERR_MALFORMED_YAML).ToString());
228+
}
229+
230+
// 2. Hand the canonical (regardless of desugar errors) to Parser.ParseJson
231+
// and then run the loader's post-parse validation passes. This mirrors
232+
// the Java runner: desugar errors do NOT short-circuit the build-tree
233+
// pipeline, so the harness can union YAML-side codes with downstream
234+
// canonical-parse + validation codes (e.g. ERR_BAD_ATTR_VALUE on a
235+
// coerced enum value where the bad element survived stringification).
236+
string canonicalJson = desugared.Canonical.ToJsonString();
237+
try
238+
{
239+
var result = Parser.ParseJson(canonicalJson, parseOpts);
240+
foreach (var e in result.Errors) AddCode(e.Code.ToString());
241+
242+
// 3. Run the post-load validation passes against the parsed tree —
243+
// mirrors what MetaDataLoader.Load() does after each source.
244+
// We call them directly because Load() would short-circuit on
245+
// ParseException from a coercion-flagged desugar.
246+
MetaData root = result.Root;
247+
// Super-resolution is internal; the canonical parser already attempted
248+
// it during the build-tree pass when DeferSuperResolution is false (the
249+
// default). We don't re-run it here for this reason.
250+
foreach (var e in ValidationPasses.ValidateSubtypeRules(root).Errors)
251+
AddCode(e.Code.ToString());
252+
foreach (var e in ValidationPasses.ValidateDataGridSortFields(root))
253+
AddCode(e.Code.ToString());
254+
foreach (var e in ValidationPasses.ValidateOriginPaths(root))
255+
AddCode(e.Code.ToString());
256+
foreach (var e in ValidationPasses.ValidateAttrSchema(root, registry).Errors)
257+
AddCode(e.Code.ToString());
258+
foreach (var e in ValidationPasses.ValidateDataGridFilterValues(root))
259+
AddCode(e.Code.ToString());
260+
foreach (var e in ValidationPasses.ValidateFieldObjectStorage(root))
261+
AddCode(e.Code.ToString());
262+
foreach (var e in ValidationPasses.ValidateTemplatePayloadRefs(root))
263+
AddCode(e.Code.ToString());
264+
foreach (var e in ValidationPasses.ValidateEnumValues(root))
265+
AddCode(e.Code.ToString());
266+
}
267+
catch (ParseException ex)
268+
{
269+
AddCode(ex.Code.ToString());
270+
}
271+
}
272+
273+
// -- expected-errors check ------------------------------------------
274+
if (fix.HasExpectedErrors)
275+
{
276+
IReadOnlyList<string>? want = null;
277+
try
278+
{
279+
var raw = File.ReadAllText(System.IO.Path.Combine(fix.Dir, "expected-errors.json"));
280+
var parsed = JsonSerializer.Deserialize<JsonElement>(raw);
281+
want = ParseExpectedErrors(parsed);
282+
}
283+
catch (Exception ex)
284+
{
285+
failures.Add($"expected-errors.json parse error: {ex.Message}");
286+
}
287+
288+
if (want is not null)
289+
{
290+
var wantSorted = want.Order(StringComparer.Ordinal).ToList();
291+
var gotSorted = orderedCodes.Order(StringComparer.Ordinal).ToList();
292+
if (!wantSorted.SequenceEqual(gotSorted, StringComparer.Ordinal))
293+
failures.Add(
294+
$"expected errors [{string.Join(", ", wantSorted)}], " +
295+
$"got [{string.Join(", ", gotSorted)}]");
296+
}
297+
return;
298+
}
299+
300+
// -- happy-path canonical serialization check ----------------------
301+
if (orderedCodes.Count > 0)
302+
{
303+
failures.Add(
304+
$"load produced errors [{string.Join(", ", orderedCodes)}]" +
305+
" — cannot compare canonical output");
306+
return;
307+
}
308+
if (!fix.HasExpected)
309+
{
310+
failures.Add("fixture declares no expectation files");
311+
return;
312+
}
313+
314+
string want2;
315+
try
316+
{
317+
want2 = File.ReadAllText(System.IO.Path.Combine(fix.Dir, "expected.json")).Trim();
318+
}
319+
catch (Exception ex)
320+
{
321+
failures.Add($"expected.json read error: {ex.Message}");
322+
return;
323+
}
324+
325+
// Load to a tree and canonical-serialize. Use a fresh loader so the
326+
// post-passes apply (single-source load of the YAML input).
327+
var loader2 = new MetaDataLoader(registry);
328+
var loadResult2 = loader2.Load(new[] {
329+
(IMetaDataSource)new InMemorySource(yaml, "input.yaml", MetaDataFormat.Yaml)
330+
});
331+
if (loadResult2.Errors.Count > 0)
332+
{
333+
failures.Add(
334+
$"loader produced errors [{string.Join(", ", loadResult2.Errors.Select(e => e.Code))}]" +
335+
" — cannot compare canonical output");
336+
return;
337+
}
338+
339+
string got = SerializerJson.CanonicalSerialize(loadResult2.Root).Trim();
340+
if (want2 != got)
341+
{
342+
failures.Add(
343+
$"canonical serialization mismatch:\n--- expected ---\n{want2}\n--- got ---\n{got}");
344+
}
345+
}
346+
347+
/// <summary>
348+
/// Parse <c>expected-errors.json</c> — accepts either an array of objects
349+
/// (<c>[{ "code": "ERR_X" }, ...]</c>) or an array of strings
350+
/// (<c>["ERR_X", ...]</c>) for forward-compat with simpler formats.
351+
/// </summary>
352+
private static IReadOnlyList<string> ParseExpectedErrors(JsonElement el)
353+
{
354+
if (el.ValueKind != JsonValueKind.Array)
355+
throw new InvalidOperationException("expected-errors.json must be a JSON array");
356+
357+
var codes = new List<string>();
358+
foreach (var entry in el.EnumerateArray())
359+
{
360+
if (entry.ValueKind == JsonValueKind.String)
361+
{
362+
codes.Add(entry.GetString()!);
363+
}
364+
else if (entry.ValueKind == JsonValueKind.Object
365+
&& entry.TryGetProperty("code", out var c)
366+
&& c.ValueKind == JsonValueKind.String)
367+
{
368+
codes.Add(c.GetString()!);
369+
}
370+
else
371+
{
372+
throw new InvalidOperationException(
373+
"expected-errors.json entries must be strings or { \"code\": \"ERR_X\" } objects");
374+
}
375+
}
376+
return codes;
377+
}
378+
}

0 commit comments

Comments
 (0)