|
| 1 | +using MetaObjects.Codegen; |
| 2 | +using MetaObjects.Codegen.Generators; |
| 3 | +using MetaObjects.Loader; |
| 4 | +using MetaObjects.Meta; |
| 5 | +using Xunit; |
| 6 | + |
| 7 | +namespace MetaObjects.Codegen.Tests; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// PayloadGenerator emission tests. Mirrors OutputParserGeneratorTests' shape (file-per- |
| 11 | +/// template.output, GenContext wiring) plus the regression this generator specifically needed: |
| 12 | +/// a template.output declared in a NAMED package with a BARE @payloadRef. ADR-0042 resolves |
| 13 | +/// that bare attr to its fully-qualified form (MetaData.Attr() returns "pkg::Name", not the |
| 14 | +/// literal string the author typed) — PayloadCodegen.EmitRecord's FindObject only matched a |
| 15 | +/// bare short name, so the top-level payload record (and its file) silently emitted empty. |
| 16 | +/// Nested @objectRef fields never hit this because FieldType already stripped them; only the |
| 17 | +/// generator's own top-level payloadRef was unstripped. |
| 18 | +/// </summary> |
| 19 | +public sealed class PayloadGeneratorTests |
| 20 | +{ |
| 21 | + private static MetaRoot Load(string model) |
| 22 | + { |
| 23 | + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "payload-gen.json")]); |
| 24 | + Assert.Empty(r.Errors); |
| 25 | + return r.Root; |
| 26 | + } |
| 27 | + |
| 28 | + private static GenContext Ctx(MetaRoot root) => new() |
| 29 | + { |
| 30 | + Entities = root.Objects(), |
| 31 | + Root = root, |
| 32 | + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, |
| 33 | + }; |
| 34 | + |
| 35 | + [Fact] |
| 36 | + public void Emits_no_files_when_no_template_output_nodes() |
| 37 | + { |
| 38 | + const string m = """ |
| 39 | + { "metadata.root": { "package": "acme::ai", "children": [ |
| 40 | + { "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "x" } } ] } }, |
| 41 | + { "template.prompt": { "name": "promptOnly", "@payloadRef": "Payload", "@textRef": "p/x", "@format": "text" } } |
| 42 | + ]}} |
| 43 | + """; |
| 44 | + var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList(); |
| 45 | + Assert.Empty(files); |
| 46 | + } |
| 47 | + |
| 48 | + // Regression: a template.output in a NAMED package (not the default/root package) with a |
| 49 | + // BARE @payloadRef. Before the fix, this emitted a file with a header comment and NOTHING |
| 50 | + // else — no record, because FindObject's bare-name match failed against the FQN attr value. |
| 51 | + [Fact] |
| 52 | + public void Emits_full_record_for_bare_payloadRef_in_a_named_package() |
| 53 | + { |
| 54 | + const string m = """ |
| 55 | + { "metadata.root": { "package": "acme::intake", "children": [ |
| 56 | + { "object.value": { "name": "NoteEntry", "children": [ |
| 57 | + { "field.string": { "name": "value" } }, |
| 58 | + { "field.string": { "name": "reasoning" } } |
| 59 | + ]}}, |
| 60 | + { "object.value": { "name": "ClassificationResponse", "children": [ |
| 61 | + { "field.string": { "name": "documentType" } }, |
| 62 | + { "field.decimal": { "name": "confidence" } }, |
| 63 | + { "field.object": { "name": "note", "@objectRef": "NoteEntry" } } |
| 64 | + ]}}, |
| 65 | + { "template.output": { "name": "ClassificationResponseTemplate", |
| 66 | + "@payloadRef": "ClassificationResponse", "@textRef": "ai/classification-response", "@format": "text" } } |
| 67 | + ]}} |
| 68 | + """; |
| 69 | + var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList(); |
| 70 | + |
| 71 | + var file = Assert.Single(files); |
| 72 | + Assert.Equal("ClassificationResponse.payload.cs", file.Path); |
| 73 | + Assert.Contains("public sealed record ClassificationResponse", file.Content); |
| 74 | + Assert.Contains("public required string documentType { get; init; }", file.Content); |
| 75 | + Assert.Contains("public required double confidence { get; init; }", file.Content); |
| 76 | + Assert.Contains("public required NoteEntry note { get; init; }", file.Content); |
| 77 | + Assert.Contains("public sealed record NoteEntry", file.Content); |
| 78 | + Assert.Contains("public required string value { get; init; }", file.Content); |
| 79 | + // The FQN must not leak into the file name or the record/type names. |
| 80 | + Assert.DoesNotContain("acme::intake::", file.Content); |
| 81 | + Assert.DoesNotContain("::", file.Path); |
| 82 | + } |
| 83 | + |
| 84 | + [Fact] |
| 85 | + public void Emits_one_file_per_template_output_with_expected_path_and_class() |
| 86 | + { |
| 87 | + const string m = """ |
| 88 | + { "metadata.root": { "package": "acme::ai", "children": [ |
| 89 | + { "object.value": { "name": "AlphaPayload", "children": [ { "field.string": { "name": "name" } } ] } }, |
| 90 | + { "object.value": { "name": "BetaPayload", "children": [ { "field.int": { "name": "n" } } ] } }, |
| 91 | + { "template.output": { "name": "Alpha", "@payloadRef": "AlphaPayload", "@textRef": "a/x", "@format": "json" } }, |
| 92 | + { "template.output": { "name": "Beta", "@payloadRef": "BetaPayload", "@textRef": "b/x", "@format": "json" } } |
| 93 | + ]}} |
| 94 | + """; |
| 95 | + var files = new PayloadGenerator().Generate(Ctx(Load(m))).OrderBy(f => f.Path).ToList(); |
| 96 | + Assert.Equal(2, files.Count); |
| 97 | + Assert.Equal("AlphaPayload.payload.cs", files[0].Path); |
| 98 | + Assert.Equal("BetaPayload.payload.cs", files[1].Path); |
| 99 | + Assert.Contains("public sealed record AlphaPayload", files[0].Content); |
| 100 | + Assert.Contains("public sealed record BetaPayload", files[1].Content); |
| 101 | + } |
| 102 | +} |
0 commit comments