Skip to content

Commit be7d47c

Browse files
myieyeclaude
andcommitted
Regenerate template.sql + add drift-detection test
Regenerate from FwData via the (updated) GenerateTemplate dev tool. The in-tree template was generated 2026-05-20 and missed migration 20260409130907_AddHomographNumbers added in PR #2220 (2026-05-22). On apply, EF would self-heal by running the missing migration on first open, but the template was technically out of sync with the schema. Add TemplateIsCurrentWithEfMigrations that applies the template to a scratch sqlite, reads __EFMigrationsHistory, diffs against the LcmCrdtDbContext migrations assembly, and fails listing any migrations the template doesn't claim applied. Coverage: - New EF migration added → caught directly. - MiniLcm data-model change → caught indirectly (model-snapshot CI check forces a corresponding migration, which then triggers this). - FieldWorks template-source changes → NOT covered. Manual responsibility tied to the FW version we point the bridge at. DriftDetection_Catches_MissingMigration confirms the mechanism would have flagged the HomographNumbers miss. It strips that migration's history row from a copy of the current template and asserts the detector reports it back as missing — proves the test works for the specific drift case we just regenerated out of. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6e799b3 commit be7d47c

2 files changed

Lines changed: 5605 additions & 5587 deletions

File tree

backend/FwLite/FwLiteProjectSync.Tests/ProjectTemplateTests.cs

Lines changed: 74 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
using LcmCrdt;
88
using LcmCrdt.Objects;
99
using LcmCrdt.Project;
10+
using System.Reflection;
1011
using LcmCrdt.Tests;
1112
using Microsoft.Data.Sqlite;
13+
using Microsoft.EntityFrameworkCore;
14+
using Microsoft.EntityFrameworkCore.Migrations;
1215
using Microsoft.Extensions.DependencyInjection;
1316
using Microsoft.Extensions.Options;
1417
using MiniLcm;
@@ -89,68 +92,82 @@ private static async Task<Guid> ReadSourceClientId(string dbPath)
8992
return Guid.Parse(idStr);
9093
}
9194

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)
94140
{
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
107143
{
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}"))
124148
{
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();
144165
}
145-
146-
var output = commitRegex.Replace(hydrated, m =>
166+
finally
147167
{
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+
}
154171
}
155172

156173
[Fact]

0 commit comments

Comments
 (0)