|
7 | 7 | using LcmCrdt; |
8 | 8 | using LcmCrdt.Objects; |
9 | 9 | using LcmCrdt.Project; |
| 10 | +using System.Reflection; |
10 | 11 | using LcmCrdt.Tests; |
11 | 12 | using Microsoft.Data.Sqlite; |
| 13 | +using Microsoft.EntityFrameworkCore; |
| 14 | +using Microsoft.EntityFrameworkCore.Migrations; |
12 | 15 | using Microsoft.Extensions.DependencyInjection; |
13 | 16 | using Microsoft.Extensions.Options; |
14 | 17 | using MiniLcm; |
@@ -89,68 +92,82 @@ private static async Task<Guid> ReadSourceClientId(string dbPath) |
89 | 92 | return Guid.Parse(idStr); |
90 | 93 | } |
91 | 94 |
|
92 | | - [Fact(Skip = "Developer tool: run locally once if regenerating template.sql to bake Guid_N tokens + morph-types placeholder into stable constants and recompute the hash chain.")] |
93 | | - public async Task BakeStaticTemplate() |
| 95 | + [Fact] |
| 96 | + public async Task TemplateIsCurrentWithEfMigrations() |
| 97 | + { |
| 98 | + // Drift detection: if any EF migration exists in the codebase but isn't claimed-applied |
| 99 | + // by the template, the template is stale and must be regenerated via GenerateTemplate. |
| 100 | + // |
| 101 | + // Coverage: |
| 102 | + // - New EF migration added → caught directly (DriftDetection_Catches_MissingMigration |
| 103 | + // exercises the mechanism explicitly using a synthesized stale template). |
| 104 | + // - MiniLcm data-model change → caught indirectly, because the model-change CI check |
| 105 | + // (`dotnet ef migrations has-pending-model-changes`) forces a migration to exist for |
| 106 | + // any model change, and that migration shows up here. |
| 107 | + // |
| 108 | + // NOT covered (regenerate manually when you bump these): |
| 109 | + // - FieldWorks template-source data changes (new canonical morph-types, etc.). Track via |
| 110 | + // the FW version we point the bridge at. |
| 111 | + var missing = await FindMissingMigrationsAgainstTemplate(ProjectTemplate.LoadEmbedded()); |
| 112 | + missing.Should().BeEmpty( |
| 113 | + "template.sql is stale — these EF migrations exist in code but aren't claimed-applied " + |
| 114 | + "by the template. Re-run ProjectTemplateTests.GenerateTemplate (requires FwData) " + |
| 115 | + "to regenerate. Missing: {0}", |
| 116 | + string.Join(", ", missing)); |
| 117 | + } |
| 118 | + |
| 119 | + [Fact] |
| 120 | + public async Task DriftDetection_Catches_MissingMigration() |
| 121 | + { |
| 122 | + // Confirms the drift mechanism would have flagged the real-world miss we just |
| 123 | + // regenerated out of the template: AddHomographNumbers was added to the codebase in |
| 124 | + // PR #2220 but the template wasn't regenerated until this branch. Strip the migration's |
| 125 | + // history row from a copy of the current template and verify TemplateIsCurrentWithEfMigrations' |
| 126 | + // detection logic reports it as missing. |
| 127 | + const string targetMigration = "20260409130907_AddHomographNumbers"; |
| 128 | + var stale = Regex.Replace( |
| 129 | + ProjectTemplate.LoadEmbedded(), |
| 130 | + $@"INSERT INTO __EFMigrationsHistory VALUES\('{Regex.Escape(targetMigration)}',[^;]+;\r?\n", |
| 131 | + ""); |
| 132 | + stale.Should().NotContain(targetMigration, "regex strip should have removed the history row"); |
| 133 | + |
| 134 | + var missing = await FindMissingMigrationsAgainstTemplate(stale); |
| 135 | + missing.Should().Contain(targetMigration, |
| 136 | + "drift detection should flag this migration as missing when the template doesn't claim it."); |
| 137 | + } |
| 138 | + |
| 139 | + private static async Task<List<string>> FindMissingMigrationsAgainstTemplate(string templateSql) |
94 | 140 | { |
95 | | - // One-shot: read the existing tokenized template.sql, substitute Guid_N tokens with |
96 | | - // deterministic constants (UUIDv5 from a fixed namespace), substitute the morph-types |
97 | | - // placeholder with its constant, then recompute every commit's Hash/ParentHash in |
98 | | - // chain order purely textually (no sqlite roundtrip needed — see CommitBase.GenerateHash |
99 | | - // for the algorithm). After this runs, ApplyAsync no longer needs to hydrate or rehash; |
100 | | - // template.sql is a valid CRDT chain with stable per-template constants and just needs |
101 | | - // its WS placeholders substituted at apply time. |
102 | | - var current = await File.ReadAllTextAsync(TemplatePath); |
103 | | - |
104 | | - var bakeNamespace = Guid.Empty; |
105 | | - var assignments = new Dictionary<int, Guid>(); |
106 | | - var hydrated = Regex.Replace(current, @"\bGuid_(\d+)\b", m => |
| 141 | + var sqliteFile = Path.Combine(Path.GetTempPath(), $"template-currency-check-{Guid.NewGuid():N}.sqlite"); |
| 142 | + try |
107 | 143 | { |
108 | | - var n = int.Parse(m.Groups[1].ValueSpan); |
109 | | - if (!assignments.TryGetValue(n, out var g)) |
110 | | - assignments[n] = g = UUIDNext.Uuid.NewNameBased(bakeNamespace, $"guid-{n}"); |
111 | | - return g.ToString().ToUpperInvariant(); |
112 | | - }); |
113 | | - var morphSeedConstant = PreDefinedData.MorphTypesSeedCommitId(bakeNamespace).ToString().ToUpperInvariant(); |
114 | | - // Inline the placeholder literal; ProjectTemplate.MorphTypesSeedCommitPlaceholder was |
115 | | - // dropped along with the per-project hydration this bake exists to replace. |
116 | | - hydrated = hydrated.Replace("{{seed-commit-morph-types}}", morphSeedConstant); |
117 | | - |
118 | | - // Parse all Commits rows, sort in chain order, recompute hashes. |
119 | | - var commitRegex = new Regex( |
120 | | - @"INSERT INTO Commits VALUES\('(?<id>[0-9A-Fa-f-]+)','(?<hash>[0-9A-Fa-f]+)','(?<parent>[0-9A-Fa-f]+)',(?<counter>\d+),'(?<dt>[^']+)'", |
121 | | - RegexOptions.Multiline); |
122 | | - var commitInfos = commitRegex.Matches(hydrated) |
123 | | - .Select(m => new |
| 144 | + await ProjectTemplate.ApplyAsync(templateSql, sqliteFile, "en", Guid.NewGuid()); |
| 145 | + |
| 146 | + var appliedInTemplate = new HashSet<string>(); |
| 147 | + await using (var conn = new SqliteConnection($"Data Source={sqliteFile}")) |
124 | 148 | { |
125 | | - Id = Guid.Parse(m.Groups["id"].Value), |
126 | | - Counter = long.Parse(m.Groups["counter"].Value), |
127 | | - DateTime = DateTimeOffset.Parse(m.Groups["dt"].Value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal), |
128 | | - }) |
129 | | - .OrderBy(c => c.DateTime).ThenBy(c => c.Counter).ThenBy(c => c.Id) |
130 | | - .ToList(); |
131 | | - |
132 | | - var newHashes = new Dictionary<Guid, (string Hash, string Parent)>(); |
133 | | - var parentHash = SIL.Harmony.Core.CommitBase.NullParentHash; |
134 | | - foreach (var c in commitInfos) |
135 | | - { |
136 | | - var idBytes = c.Id.ToByteArray(); |
137 | | - var parentHashBytes = Convert.FromHexString(parentHash); |
138 | | - Span<byte> hashBytes = stackalloc byte[idBytes.Length + parentHashBytes.Length]; |
139 | | - idBytes.AsSpan().CopyTo(hashBytes); |
140 | | - parentHashBytes.AsSpan().CopyTo(hashBytes[idBytes.Length..]); |
141 | | - var hash = Convert.ToHexString(System.IO.Hashing.XxHash64.Hash(hashBytes)); |
142 | | - newHashes[c.Id] = (hash, parentHash); |
143 | | - parentHash = hash; |
| 149 | + await conn.OpenAsync(); |
| 150 | + await using var cmd = conn.CreateCommand(); |
| 151 | + cmd.CommandText = "SELECT MigrationId FROM __EFMigrationsHistory"; |
| 152 | + await using var reader = await cmd.ExecuteReaderAsync(); |
| 153 | + while (await reader.ReadAsync()) appliedInTemplate.Add(reader.GetString(0)); |
| 154 | + } |
| 155 | + |
| 156 | + // Enumerate migrations via reflection on the migrations assembly — avoids needing a |
| 157 | + // configured project context, which DbContextFactory would otherwise require. |
| 158 | + var codeMigrations = typeof(LcmCrdtDbContext).Assembly.GetTypes() |
| 159 | + .Select(t => t.GetCustomAttribute<MigrationAttribute>()?.Id) |
| 160 | + .Where(id => id is not null) |
| 161 | + .Cast<string>() |
| 162 | + .ToHashSet(); |
| 163 | + |
| 164 | + return codeMigrations.Except(appliedInTemplate).OrderBy(x => x).ToList(); |
144 | 165 | } |
145 | | - |
146 | | - var output = commitRegex.Replace(hydrated, m => |
| 166 | + finally |
147 | 167 | { |
148 | | - var id = Guid.Parse(m.Groups["id"].Value); |
149 | | - var (hash, parent) = newHashes[id]; |
150 | | - return $"INSERT INTO Commits VALUES('{m.Groups["id"].Value}','{hash}','{parent}',{m.Groups["counter"].Value},'{m.Groups["dt"].Value}'"; |
151 | | - }); |
152 | | - |
153 | | - await File.WriteAllTextAsync(TemplatePath, output); |
| 168 | + SqliteConnection.ClearAllPools(); |
| 169 | + try { File.Delete(sqliteFile); } catch (IOException) { /* best-effort */ } |
| 170 | + } |
154 | 171 | } |
155 | 172 |
|
156 | 173 | [Fact] |
|
0 commit comments