|
| 1 | +using MetaObjects.Cli; |
| 2 | +using Xunit; |
| 3 | + |
| 4 | +namespace MetaObjects.Cli.Tests; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// ADR-0021 D2 fan-out — `dotnet meta verify` explicit subverbs in the C# port. |
| 8 | +/// --templates : the historical template/prompt drift gate (unchanged behavior). |
| 9 | +/// --codegen : regenerate-to-temp + diff against the committed --out dir. |
| 10 | +/// --db : NOT supported in C# (schema verify is the migrate engine) → exit 2. |
| 11 | +/// A bare `verify` keeps the historical default = templates (back-compat). |
| 12 | +/// Combining flags runs each + aggregates the exit code (max; non-zero on any drift). |
| 13 | +/// |
| 14 | +/// These drive the pure-logic dispatch <see cref="VerifyCommand.RunSubverbs"/>, which |
| 15 | +/// returns a structured outcome (per-mode results + aggregate exit) with no console I/O. |
| 16 | +/// </summary> |
| 17 | +public sealed class VerifySubverbTests : IDisposable |
| 18 | +{ |
| 19 | + private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-verify-sub-" + Guid.NewGuid().ToString("N")); |
| 20 | + private string MetaDir => Path.Combine(_tmp, "metaobjects"); |
| 21 | + private string TplDir => Path.Combine(_tmp, "templates"); |
| 22 | + private string OutDir => Path.Combine(_tmp, "generated"); |
| 23 | + |
| 24 | + // template.prompt metadata (for the --templates path). |
| 25 | + private const string TemplateMetadata = """ |
| 26 | + { "metadata.root": { "package": "acme::ai", "children": [ |
| 27 | + { "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "name" } } ] } }, |
| 28 | + { "template.prompt": { "name": "greeting", "@payloadRef": "Payload", "@textRef": "t/main", "@format": "text" } } |
| 29 | + ]}} |
| 30 | + """; |
| 31 | + |
| 32 | + // object.entity metadata (for the --codegen path). |
| 33 | + private const string EntityMetadata = """ |
| 34 | + { "metadata.root": { "package": "acme", "children": [ |
| 35 | + { "object.entity": { "name": "Subscriber", "children": [ |
| 36 | + { "source.rdb": { "@table": "subscribers" } }, |
| 37 | + { "field.long": { "name": "id" } }, |
| 38 | + { "field.string": { "name": "email", "@required": true } }, |
| 39 | + { "identity.primary": { "@fields": "id" } } |
| 40 | + ]}} |
| 41 | + ]}} |
| 42 | + """; |
| 43 | + |
| 44 | + public VerifySubverbTests() |
| 45 | + { |
| 46 | + Directory.CreateDirectory(MetaDir); |
| 47 | + Directory.CreateDirectory(TplDir); |
| 48 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), TemplateMetadata); |
| 49 | + } |
| 50 | + |
| 51 | + public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } } |
| 52 | + |
| 53 | + private void WriteTemplate(string body) |
| 54 | + { |
| 55 | + var dir = Path.Combine(TplDir, "t"); |
| 56 | + Directory.CreateDirectory(dir); |
| 57 | + File.WriteAllText(Path.Combine(dir, "main.mustache"), body); |
| 58 | + } |
| 59 | + |
| 60 | + private VerifyCommand.Options TemplatesOpts(bool templates = true, bool codegen = false, bool db = false) => |
| 61 | + new() |
| 62 | + { |
| 63 | + MetadataDir = MetaDir, |
| 64 | + TemplatesRoot = TplDir, |
| 65 | + OutDir = OutDir, |
| 66 | + // Match the namespace the committed output was generated with, so a |
| 67 | + // clean regen is byte-identical (namespace is embedded in the output). |
| 68 | + Namespace = "Acme.Generated", |
| 69 | + Templates = templates, |
| 70 | + Codegen = codegen, |
| 71 | + Db = db, |
| 72 | + }; |
| 73 | + |
| 74 | + // -------------------- --templates (existing behavior under the flag) ----- |
| 75 | + |
| 76 | + [Fact] |
| 77 | + public void Templates_clean_is_exit0() |
| 78 | + { |
| 79 | + WriteTemplate("Hi {{name}}."); |
| 80 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts()); |
| 81 | + Assert.Equal(0, r.ExitCode); |
| 82 | + Assert.True(r.RanTemplates); |
| 83 | + Assert.False(r.RanCodegen); |
| 84 | + } |
| 85 | + |
| 86 | + [Fact] |
| 87 | + public void Templates_drift_is_nonzero() |
| 88 | + { |
| 89 | + WriteTemplate("Hi {{name}}, you have {{notAField}}."); |
| 90 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts()); |
| 91 | + Assert.NotEqual(0, r.ExitCode); |
| 92 | + } |
| 93 | + |
| 94 | + // -------------------- bare verify = templates + note (back-compat) ------- |
| 95 | + |
| 96 | + [Fact] |
| 97 | + public void Bare_verify_defaults_to_templates_and_emits_the_subverb_note() |
| 98 | + { |
| 99 | + WriteTemplate("Hi {{name}}."); |
| 100 | + // No explicit subverb selected. |
| 101 | + var opts = new VerifyCommand.Options |
| 102 | + { |
| 103 | + MetadataDir = MetaDir, |
| 104 | + TemplatesRoot = TplDir, |
| 105 | + OutDir = OutDir, |
| 106 | + Templates = false, |
| 107 | + Codegen = false, |
| 108 | + Db = false, |
| 109 | + }; |
| 110 | + var r = VerifyCommand.RunSubverbs(opts); |
| 111 | + Assert.Equal(0, r.ExitCode); |
| 112 | + Assert.True(r.RanTemplates); // defaulted to templates |
| 113 | + Assert.True(r.EmittedDefaultNote); // one-line note that subverbs exist |
| 114 | + } |
| 115 | + |
| 116 | + // -------------------- --db rejected in C# -------------------------------- |
| 117 | + |
| 118 | + [Fact] |
| 119 | + public void Db_subverb_is_rejected_exit2_with_message() |
| 120 | + { |
| 121 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, db: true)); |
| 122 | + Assert.Equal(2, r.ExitCode); |
| 123 | + Assert.NotNull(r.DbRejectionMessage); |
| 124 | + Assert.Contains("migrate", r.DbRejectionMessage!, StringComparison.OrdinalIgnoreCase); |
| 125 | + } |
| 126 | + |
| 127 | + // -------------------- --codegen ----------------------------------------- |
| 128 | + |
| 129 | + [Fact] |
| 130 | + public void Codegen_clean_committed_output_is_exit0() |
| 131 | + { |
| 132 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 133 | + GenCommand.Run(MetaDir, OutDir, "Acme.Generated"); |
| 134 | + |
| 135 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true)); |
| 136 | + Assert.Equal(0, r.ExitCode); |
| 137 | + Assert.True(r.RanCodegen); |
| 138 | + Assert.NotNull(r.Codegen); |
| 139 | + Assert.True(r.Codegen!.Clean); |
| 140 | + } |
| 141 | + |
| 142 | + [Fact] |
| 143 | + public void Codegen_mutated_committed_file_is_nonzero_and_named() |
| 144 | + { |
| 145 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 146 | + GenCommand.Run(MetaDir, OutDir, "Acme.Generated"); |
| 147 | + File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "\n// drift\n"); |
| 148 | + |
| 149 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true)); |
| 150 | + Assert.NotEqual(0, r.ExitCode); |
| 151 | + Assert.Contains(r.Codegen!.DriftedFiles, f => f.EndsWith("Subscriber.g.cs")); |
| 152 | + } |
| 153 | + |
| 154 | + [Fact] |
| 155 | + public void Codegen_does_not_touch_the_real_out_dir_on_drift() |
| 156 | + { |
| 157 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 158 | + GenCommand.Run(MetaDir, OutDir, "Acme.Generated"); |
| 159 | + File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "\n// drift\n"); |
| 160 | + |
| 161 | + var before = SnapshotDir(OutDir); |
| 162 | + VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true)); |
| 163 | + var after = SnapshotDir(OutDir); |
| 164 | + Assert.Equal(before, after); |
| 165 | + } |
| 166 | + |
| 167 | + [Fact] |
| 168 | + public void Codegen_without_committed_output_is_exit2() |
| 169 | + { |
| 170 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 171 | + // Never ran gen → OutDir absent → nothing to diff against → exit 2. |
| 172 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true)); |
| 173 | + Assert.Equal(2, r.ExitCode); |
| 174 | + Assert.NotNull(r.Codegen!.Error); |
| 175 | + } |
| 176 | + |
| 177 | + // -------------------- the namespace-inference footgun -------------------- |
| 178 | + // gen used a CUSTOM namespace; verify --codegen WITHOUT --namespace must infer |
| 179 | + // it from the committed output (else every file would spuriously drift on the |
| 180 | + // embedded `namespace {ns};`). Build the Options with NO explicit namespace. |
| 181 | + |
| 182 | + /// <summary>Codegen opts with NO explicit namespace (the footgun scenario).</summary> |
| 183 | + private VerifyCommand.Options CodegenOptsNoNamespace() => |
| 184 | + new() |
| 185 | + { |
| 186 | + MetadataDir = MetaDir, |
| 187 | + OutDir = OutDir, |
| 188 | + Codegen = true, |
| 189 | + // Namespace intentionally NOT set + NamespaceExplicit defaults to false. |
| 190 | + }; |
| 191 | + |
| 192 | + [Fact] |
| 193 | + public void Codegen_infers_custom_namespace_from_committed_output_no_flag() |
| 194 | + { |
| 195 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 196 | + // Committed output generated with a CUSTOM namespace. |
| 197 | + GenCommand.Run(MetaDir, OutDir, "Acme.Generated"); |
| 198 | + |
| 199 | + // verify --codegen WITHOUT --namespace → must infer "Acme.Generated" from the |
| 200 | + // committed files and produce a byte-identical regen → exit 0 (no spurious drift). |
| 201 | + var r = VerifyCommand.RunSubverbs(CodegenOptsNoNamespace()); |
| 202 | + Assert.Equal(0, r.ExitCode); |
| 203 | + Assert.True(r.Codegen!.Clean, string.Join("; ", r.Codegen!.Lines)); |
| 204 | + } |
| 205 | + |
| 206 | + [Fact] |
| 207 | + public void Codegen_inference_still_detects_real_drift_with_custom_namespace() |
| 208 | + { |
| 209 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 210 | + GenCommand.Run(MetaDir, OutDir, "Acme.Generated"); |
| 211 | + // A real hand-edit on top of the custom namespace must still be drift. |
| 212 | + File.AppendAllText(Path.Combine(OutDir, "Subscriber.g.cs"), "\n// real drift\n"); |
| 213 | + |
| 214 | + var r = VerifyCommand.RunSubverbs(CodegenOptsNoNamespace()); |
| 215 | + Assert.NotEqual(0, r.ExitCode); |
| 216 | + Assert.Contains(r.Codegen!.DriftedFiles, f => f.EndsWith("Subscriber.g.cs")); |
| 217 | + } |
| 218 | + |
| 219 | + [Fact] |
| 220 | + public void Codegen_explicit_namespace_still_wins_over_inference() |
| 221 | + { |
| 222 | + File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EntityMetadata); |
| 223 | + GenCommand.Run(MetaDir, OutDir, "Acme.Generated"); |
| 224 | + |
| 225 | + // Explicit namespace set (matching) → wins, byte-identical regen → exit 0. |
| 226 | + var opts = new VerifyCommand.Options |
| 227 | + { |
| 228 | + MetadataDir = MetaDir, |
| 229 | + OutDir = OutDir, |
| 230 | + Namespace = "Acme.Generated", |
| 231 | + NamespaceExplicit = true, |
| 232 | + Codegen = true, |
| 233 | + }; |
| 234 | + var r = VerifyCommand.RunSubverbs(opts); |
| 235 | + Assert.Equal(0, r.ExitCode); |
| 236 | + Assert.True(r.Codegen!.Clean, string.Join("; ", r.Codegen!.Lines)); |
| 237 | + } |
| 238 | + |
| 239 | + // -------------------- combining flags aggregates exit -------------------- |
| 240 | + |
| 241 | + [Fact] |
| 242 | + public void Combining_templates_and_codegen_aggregates_max_exit() |
| 243 | + { |
| 244 | + // Templates clean (exit 0) but codegen has no committed output (exit 2). |
| 245 | + WriteTemplate("Hi {{name}}."); |
| 246 | + var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: true, codegen: true)); |
| 247 | + Assert.True(r.RanTemplates); |
| 248 | + Assert.True(r.RanCodegen); |
| 249 | + Assert.Equal(2, r.ExitCode); // max(0, 2) |
| 250 | + } |
| 251 | + |
| 252 | + private static Dictionary<string, string> SnapshotDir(string dir) |
| 253 | + { |
| 254 | + var snap = new Dictionary<string, string>(StringComparer.Ordinal); |
| 255 | + if (!Directory.Exists(dir)) return snap; |
| 256 | + foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) |
| 257 | + snap[Path.GetRelativePath(dir, f)] = File.ReadAllText(f); |
| 258 | + return snap; |
| 259 | + } |
| 260 | +} |
0 commit comments