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