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