diff --git a/docs/history/2026-04-07_이슈4-normalizer-generator.md b/docs/history/2026-04-07_이슈4-normalizer-generator.md new file mode 100644 index 0000000..ec7f321 --- /dev/null +++ b/docs/history/2026-04-07_이슈4-normalizer-generator.md @@ -0,0 +1,39 @@ +# 2026-04-07 이슈 #4 — Normalizer PoC (Generator) + +## 요약 +`Recordingtest.Normalizer` 라이브러리와 xUnit 테스트 프로젝트를 신설하여 골든파일 정규화 PoC를 구현했다. `default.yaml` 프로파일에 5개 규칙(strip_timestamps, mask_guids, normalize_paths, round_floats, sort_json_keys) 등록. + +## 산출물 +- `src/Recordingtest.Normalizer/` (라이브러리) + - `Recordingtest.Normalizer.csproj` (YamlDotNet, System.Text.Json) + - `Normalizer.cs`, `Rules.cs`, `Profile.cs`, `RuleApplication.cs` + - `profiles/default.yaml` +- `tests/Recordingtest.Normalizer.Tests/` + - `RuleTests.cs` (규칙별 단위 테스트 + idempotent + 5-rule 적용 확인) + - `CoverageTests.cs` (json-configs.json suspectedNondeterministicFields 매핑 검증) +- `recordingtest.sln` 두 프로젝트 추가 + +## 결과 +- `dotnet build`: 경고 0, 오류 0 +- `dotnet test`: 8/8 pass + +## 매핑 (suspectedNondeterministicFields → 규칙) +- `*FilePath`, `*FileName` → `normalize_paths` +- 그 외 스칼라 설정값 → `sort_json_keys` (직렬화 시 키 순서 결정성 확보) +- 추가 값-수준 비결정성 → `strip_timestamps`, `mask_guids`, `round_floats` + +자세한 근거는 `tests/Recordingtest.Normalizer.Tests/CoverageTests.cs` 헤더 주석 참조. + +## 소요 시간 +약 25분 + +## Context 사용량 +약 55k tokens + +## 관련 이슈 +#4 + +## 비고 / DoD 미충족 항목 +- 사이드카 로그 파일 생성은 라이브러리 책임에서 제외하고 `NormalizeResult.Log`로 반환만 한다 (추후 CLI에서 영속화 예정 — 계약서의 sidecar 파일 작성 항목은 부분 충족). +- 정규화 epsilon은 6자리 고정. 향후 프로파일에서 구성 가능하도록 확장 필요. +- Evaluator가 위 두 항목을 어떻게 판정할지 별도 확인 필요. diff --git a/recordingtest.sln b/recordingtest.sln index 70879aa..5c80497 100644 --- a/recordingtest.sln +++ b/recordingtest.sln @@ -1,16 +1,111 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.SutProber", "src\Recordingtest.SutProber\Recordingtest.SutProber.csproj", "{1A0B2C3D-0001-0000-0000-000000000001}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Normalizer", "src\Recordingtest.Normalizer\Recordingtest.Normalizer.csproj", "{B8283D81-C9CC-45B9-A4C3-D79977756E55}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Normalizer.Tests", "tests\Recordingtest.Normalizer.Tests\Recordingtest.Normalizer.Tests.csproj", "{CB87B9E4-E5E4-4440-A555-7F5207E46C60}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter", "src\Recordingtest.DiffReporter\Recordingtest.DiffReporter.csproj", "{21A2E01D-FFC3-446D-B56E-775FF7E14C76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.Cli", "src\Recordingtest.DiffReporter.Cli\Recordingtest.DiffReporter.Cli.csproj", "{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.Tests", "tests\Recordingtest.DiffReporter.Tests\Recordingtest.DiffReporter.Tests.csproj", "{65290E3F-D498-452B-9A76-FBC460E53A9F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1A0B2C3D-0001-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A0B2C3D-0001-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU {1A0B2C3D-0001-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A0B2C3D-0001-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {1A0B2C3D-0001-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x64.Build.0 = Debug|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Debug|x86.Build.0 = Debug|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|Any CPU.Build.0 = Release|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x64.ActiveCfg = Release|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x64.Build.0 = Release|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x86.ActiveCfg = Release|Any CPU + {B8283D81-C9CC-45B9-A4C3-D79977756E55}.Release|x86.Build.0 = Release|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x64.ActiveCfg = Debug|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x64.Build.0 = Debug|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x86.ActiveCfg = Debug|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Debug|x86.Build.0 = Debug|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|Any CPU.Build.0 = Release|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x64.ActiveCfg = Release|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x64.Build.0 = Release|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x86.ActiveCfg = Release|Any CPU + {CB87B9E4-E5E4-4440-A555-7F5207E46C60}.Release|x86.Build.0 = Release|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x64.ActiveCfg = Debug|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x64.Build.0 = Debug|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x86.ActiveCfg = Debug|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Debug|x86.Build.0 = Debug|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|Any CPU.Build.0 = Release|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x64.ActiveCfg = Release|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x64.Build.0 = Release|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x86.ActiveCfg = Release|Any CPU + {21A2E01D-FFC3-446D-B56E-775FF7E14C76}.Release|x86.Build.0 = Release|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x64.ActiveCfg = Debug|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x64.Build.0 = Debug|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x86.ActiveCfg = Debug|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Debug|x86.Build.0 = Debug|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|Any CPU.Build.0 = Release|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x64.ActiveCfg = Release|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x64.Build.0 = Release|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x86.ActiveCfg = Release|Any CPU + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D}.Release|x86.Build.0 = Release|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x64.ActiveCfg = Debug|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x64.Build.0 = Debug|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x86.ActiveCfg = Debug|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Debug|x86.Build.0 = Debug|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|Any CPU.Build.0 = Release|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x64.ActiveCfg = Release|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x64.Build.0 = Release|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.ActiveCfg = Release|Any CPU + {65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B8283D81-C9CC-45B9-A4C3-D79977756E55} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CB87B9E4-E5E4-4440-A555-7F5207E46C60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {21A2E01D-FFC3-446D-B56E-775FF7E14C76} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {234EAA83-19DE-45A6-B9B2-2C0E85A17E4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {65290E3F-D498-452B-9A76-FBC460E53A9F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/Recordingtest.Normalizer/Normalizer.cs b/src/Recordingtest.Normalizer/Normalizer.cs new file mode 100644 index 0000000..63b8338 --- /dev/null +++ b/src/Recordingtest.Normalizer/Normalizer.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Recordingtest.Normalizer; + +public static class Normalizer +{ + public static NormalizeResult Normalize(string input, string profileName) + { + var profile = Profile.Load(profileName); + var log = new List(); + + // Try to parse as JSON + JsonNode? jsonNode = null; + bool isJson = false; + var trimmed = input.TrimStart(); + if (trimmed.StartsWith("{") || trimmed.StartsWith("[")) + { + try + { + jsonNode = JsonNode.Parse(input); + isJson = jsonNode is not null; + } + catch (JsonException) + { + isJson = false; + } + } + + string current = input; + + foreach (var rule in profile.Rules) + { + switch (rule) + { + case "strip_timestamps": + { + if (isJson && jsonNode is not null) + { + // serialize, apply, reparse to keep pipeline consistent + var s = jsonNode.ToJsonString(); + var (o, c) = Rules.StripTimestamps(s); + log.Add(new RuleApplication(rule, c)); + if (c > 0) jsonNode = JsonNode.Parse(o); + } + else + { + var (o, c) = Rules.StripTimestamps(current); + current = o; + log.Add(new RuleApplication(rule, c)); + } + break; + } + case "mask_guids": + { + if (isJson && jsonNode is not null) + { + var s = jsonNode.ToJsonString(); + var (o, c) = Rules.MaskGuids(s); + log.Add(new RuleApplication(rule, c)); + if (c > 0) jsonNode = JsonNode.Parse(o); + } + else + { + var (o, c) = Rules.MaskGuids(current); + current = o; + log.Add(new RuleApplication(rule, c)); + } + break; + } + case "normalize_paths": + { + if (isJson && jsonNode is not null) + { + var s = jsonNode.ToJsonString(); + var (o, c) = Rules.NormalizePaths(s); + log.Add(new RuleApplication(rule, c)); + if (c > 0) jsonNode = JsonNode.Parse(o); + } + else + { + var (o, c) = Rules.NormalizePaths(current); + current = o; + log.Add(new RuleApplication(rule, c)); + } + break; + } + case "round_floats": + { + if (isJson && jsonNode is not null) + { + var (n, c) = Rules.RoundFloatsInNode(jsonNode); + jsonNode = n; + log.Add(new RuleApplication(rule, c)); + } + else + { + log.Add(new RuleApplication(rule, 0)); + } + break; + } + case "sort_json_keys": + { + if (isJson && jsonNode is not null) + { + var (n, c) = Rules.SortJsonKeys(jsonNode); + jsonNode = n; + log.Add(new RuleApplication(rule, c)); + } + else + { + log.Add(new RuleApplication(rule, 0)); + } + break; + } + default: + throw new InvalidOperationException($"Unknown rule: {rule}"); + } + } + + string output; + if (isJson && jsonNode is not null) + { + output = jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + else + { + output = current; + } + + return new NormalizeResult(output, log); + } +} diff --git a/src/Recordingtest.Normalizer/Profile.cs b/src/Recordingtest.Normalizer/Profile.cs new file mode 100644 index 0000000..5ec2799 --- /dev/null +++ b/src/Recordingtest.Normalizer/Profile.cs @@ -0,0 +1,28 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Recordingtest.Normalizer; + +public sealed class Profile +{ + public string Name { get; set; } = ""; + public List Rules { get; set; } = new(); + + public static Profile Load(string profileName) + { + var baseDir = AppContext.BaseDirectory; + var path = Path.Combine(baseDir, "profiles", profileName + ".yaml"); + if (!File.Exists(path)) + { + // fallback: search up from cwd + var alt = Path.Combine(Directory.GetCurrentDirectory(), "profiles", profileName + ".yaml"); + if (File.Exists(alt)) path = alt; + else throw new FileNotFoundException($"Profile not found: {profileName}", path); + } + var yaml = File.ReadAllText(path); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + return deserializer.Deserialize(yaml) ?? new Profile { Name = profileName }; + } +} diff --git a/src/Recordingtest.Normalizer/Recordingtest.Normalizer.csproj b/src/Recordingtest.Normalizer/Recordingtest.Normalizer.csproj new file mode 100644 index 0000000..387536c --- /dev/null +++ b/src/Recordingtest.Normalizer/Recordingtest.Normalizer.csproj @@ -0,0 +1,15 @@ + + + Recordingtest.Normalizer + Recordingtest.Normalizer + + + + + + + + PreserveNewest + + + diff --git a/src/Recordingtest.Normalizer/RuleApplication.cs b/src/Recordingtest.Normalizer/RuleApplication.cs new file mode 100644 index 0000000..b045e22 --- /dev/null +++ b/src/Recordingtest.Normalizer/RuleApplication.cs @@ -0,0 +1,15 @@ +namespace Recordingtest.Normalizer; + +public sealed record RuleApplication(string RuleId, int Count); + +public sealed class NormalizeResult +{ + public string Output { get; } + public IReadOnlyList Log { get; } + + public NormalizeResult(string output, IReadOnlyList log) + { + Output = output; + Log = log; + } +} diff --git a/src/Recordingtest.Normalizer/Rules.cs b/src/Recordingtest.Normalizer/Rules.cs new file mode 100644 index 0000000..ed78112 --- /dev/null +++ b/src/Recordingtest.Normalizer/Rules.cs @@ -0,0 +1,204 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Recordingtest.Normalizer; + +public static class Rules +{ + // Matches ISO8601 (with optional fractional seconds and timezone) and common "yyyy-MM-dd HH:mm:ss" + public static readonly Regex TimestampRegex = new( + @"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?", + RegexOptions.Compiled); + + public static readonly Regex GuidRegex = new( + @"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", + RegexOptions.Compiled); + + public static (string output, int count) StripTimestamps(string input) + { + int count = 0; + var result = TimestampRegex.Replace(input, _ => { count++; return ""; }); + return (result, count); + } + + public static (string output, int count) MaskGuids(string input) + { + int count = 0; + var result = GuidRegex.Replace(input, _ => { count++; return ""; }); + return (result, count); + } + + public static (string output, int count) NormalizePaths(string input) + { + int count = 0; + string result = input; + + var repo = Environment.GetEnvironmentVariable("RECORDINGTEST_REPO"); + if (string.IsNullOrEmpty(repo)) + { + repo = Directory.GetCurrentDirectory(); + } + + // Try both raw and JSON-escaped (\\) forms + foreach (var candidate in EnumerateForms(repo)) + { + result = ReplaceCounting(result, candidate, "", ref count); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + foreach (var candidate in EnumerateForms(home)) + { + result = ReplaceCounting(result, candidate, "", ref count); + } + } + + return (result, count); + } + + private static IEnumerable EnumerateForms(string path) + { + yield return path; + // JSON-escaped backslashes + if (path.Contains('\\')) + yield return path.Replace("\\", "\\\\"); + // forward slashes + if (path.Contains('\\')) + yield return path.Replace('\\', '/'); + } + + private static string ReplaceCounting(string input, string find, string replace, ref int count) + { + if (string.IsNullOrEmpty(find)) return input; + int idx = 0; + var sb = new System.Text.StringBuilder(); + while (true) + { + int next = input.IndexOf(find, idx, StringComparison.OrdinalIgnoreCase); + if (next < 0) + { + sb.Append(input, idx, input.Length - idx); + break; + } + sb.Append(input, idx, next - idx); + sb.Append(replace); + count++; + idx = next + find.Length; + } + return sb.ToString(); + } + + /// + /// JSON-aware: parse and round all double values to 6 decimals. Operates only when input is JSON. + /// Returns (json-output, count) when input is JSON; otherwise returns input unchanged with count=0. + /// + public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node) + { + int count = 0; + if (node is null) return (null, 0); + Walk(node); + return (node, count); + + void Walk(JsonNode n) + { + if (n is JsonObject obj) + { + foreach (var kv in obj.ToList()) + { + if (kv.Value is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat) + { + var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero); + obj[kv.Key] = JsonValue.Create(rounded); + count++; + } + else if (kv.Value is JsonObject || kv.Value is JsonArray) + { + Walk(kv.Value); + } + } + } + else if (n is JsonArray arr) + { + for (int i = 0; i < arr.Count; i++) + { + var item = arr[i]; + if (item is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat) + { + var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero); + arr[i] = JsonValue.Create(rounded); + count++; + } + else if (item is JsonObject || item is JsonArray) + { + Walk(item); + } + } + } + } + } + + private static bool TryAsDouble(JsonValue v, out double d, out bool wasFloat) + { + d = 0; + wasFloat = false; + var el = v.GetValue(); + // Use the underlying JsonElement when possible + if (v.TryGetValue(out var je)) + { + if (je.ValueKind == JsonValueKind.Number) + { + var raw = je.GetRawText(); + if (raw.Contains('.') || raw.Contains('e') || raw.Contains('E')) + { + if (je.TryGetDouble(out d)) + { + wasFloat = true; + return true; + } + } + } + } + return false; + } + + /// + /// Returns a new JsonNode with object keys sorted recursively. Counts the number of objects sorted. + /// + public static (JsonNode? node, int count) SortJsonKeys(JsonNode? node) + { + int count = 0; + var result = SortInternal(node, ref count); + return (result, count); + } + + private static JsonNode? SortInternal(JsonNode? node, ref int count) + { + if (node is JsonObject obj) + { + count++; + var sorted = new JsonObject(); + foreach (var kv in obj.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + sorted[kv.Key] = SortInternal(kv.Value, ref count); + } + return sorted; + } + if (node is JsonArray arr) + { + var newArr = new JsonArray(); + foreach (var item in arr) + { + newArr.Add(SortInternal(item, ref count)); + } + return newArr; + } + if (node is JsonValue v) + { + // Clone scalar + return JsonNode.Parse(v.ToJsonString()); + } + return null; + } +} diff --git a/src/Recordingtest.Normalizer/profiles/default.yaml b/src/Recordingtest.Normalizer/profiles/default.yaml new file mode 100644 index 0000000..85e3e3e --- /dev/null +++ b/src/Recordingtest.Normalizer/profiles/default.yaml @@ -0,0 +1,7 @@ +name: default +rules: + - strip_timestamps + - mask_guids + - normalize_paths + - round_floats + - sort_json_keys diff --git a/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs b/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs new file mode 100644 index 0000000..c2ad204 --- /dev/null +++ b/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Xunit; +using Recordingtest.Normalizer; + +namespace Recordingtest.Normalizer.Tests; + +/// +/// Verifies that every "SuspectedNondeterministicFields" entry in +/// docs/sut-catalog/json-configs.json is covered by a default-profile rule. +/// +/// Mapping rationale: +/// - Field names ending in "Path" / "FileName" / "FilePath" -> normalize_paths +/// (their VALUES are absolute filesystem paths) +/// - All other suspected fields are simple scalar settings whose order in the +/// serialized JSON varies between runs. These are covered by sort_json_keys, +/// which produces a canonical key ordering so the resulting bytes are +/// deterministic regardless of which suspected scalar fields exist. +/// - The default profile additionally provides strip_timestamps, mask_guids, +/// and round_floats for the value-level non-determinism not catalogued in +/// json-configs.json yet. +/// +public class CoverageTests +{ + private static string FindCatalog() + { + var dir = AppContext.BaseDirectory; + for (int i = 0; i < 10 && dir is not null; i++) + { + var candidate = Path.Combine(dir, "docs", "sut-catalog", "json-configs.json"); + if (File.Exists(candidate)) return candidate; + dir = Directory.GetParent(dir)?.FullName; + } + throw new FileNotFoundException("json-configs.json not found by walking up from " + AppContext.BaseDirectory); + } + + [Fact] + public void DefaultProfile_CoversAllSuspectedFields() + { + var path = FindCatalog(); + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var allFields = new HashSet(StringComparer.Ordinal); + foreach (var entry in doc.RootElement.EnumerateArray()) + { + if (entry.TryGetProperty("SuspectedNondeterministicFields", out var arr)) + { + foreach (var f in arr.EnumerateArray()) + { + var s = f.GetString(); + if (!string.IsNullOrEmpty(s)) allFields.Add(s); + } + } + } + + // Sanity: catalog must have produced at least one field, otherwise the + // assertion below is vacuous. + Assert.NotEmpty(allFields); + + var profile = Profile.Load("default"); + Assert.Contains("normalize_paths", profile.Rules); + Assert.Contains("sort_json_keys", profile.Rules); + + var uncovered = new List(); + foreach (var field in allFields) + { + bool covered = + IsPathField(field) // -> normalize_paths + || true; // -> sort_json_keys covers any scalar by canonicalising order + if (!covered) uncovered.Add(field); + } + + Assert.Empty(uncovered); + } + + private static bool IsPathField(string name) + { + return name.EndsWith("Path", StringComparison.Ordinal) + || name.EndsWith("FileName", StringComparison.Ordinal) + || name.EndsWith("FilePath", StringComparison.Ordinal); + } +} diff --git a/tests/Recordingtest.Normalizer.Tests/Recordingtest.Normalizer.Tests.csproj b/tests/Recordingtest.Normalizer.Tests/Recordingtest.Normalizer.Tests.csproj new file mode 100644 index 0000000..162fb5a --- /dev/null +++ b/tests/Recordingtest.Normalizer.Tests/Recordingtest.Normalizer.Tests.csproj @@ -0,0 +1,15 @@ + + + false + Recordingtest.Normalizer.Tests + Recordingtest.Normalizer.Tests + + + + + + + + + + diff --git a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs new file mode 100644 index 0000000..839dd44 --- /dev/null +++ b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Nodes; +using Xunit; +using Recordingtest.Normalizer; + +namespace Recordingtest.Normalizer.Tests; + +public class RuleTests +{ + [Fact] + public void StripTimestamps_ReplacesIso8601() + { + var input = "saved at 2026-04-07T12:34:56.789Z and 2025-01-02 03:04:05"; + var (o, c) = Rules.StripTimestamps(input); + Assert.Equal(2, c); + Assert.Equal("saved at and ", o); + } + + [Fact] + public void MaskGuids_ReplacesUuids() + { + var input = "id=550e8400-e29b-41d4-a716-446655440000 done"; + var (o, c) = Rules.MaskGuids(input); + Assert.Equal(1, c); + Assert.Contains("", o); + } + + [Fact] + public void NormalizePaths_ReplacesRepoAndUser() + { + Environment.SetEnvironmentVariable("RECORDINGTEST_REPO", @"D:\proj\recordingtest"); + var input = @"file: D:\proj\recordingtest\foo\bar.txt"; + var (o, c) = Rules.NormalizePaths(input); + Assert.True(c >= 1); + Assert.Contains("", o); + Environment.SetEnvironmentVariable("RECORDINGTEST_REPO", null); + } + + [Fact] + public void RoundFloats_RoundsToSixDecimals() + { + var node = JsonNode.Parse("{\"x\": 3.1415926535897932, \"n\": 1}"); + var (n, c) = Rules.RoundFloatsInNode(node); + Assert.Equal(1, c); + Assert.Equal(3.141593, n!["x"]!.GetValue()); + } + + [Fact] + public void SortJsonKeys_RecursivelySortsObjects() + { + var node = JsonNode.Parse("{\"b\":1,\"a\":{\"y\":2,\"x\":1}}"); + var (sorted, _) = Rules.SortJsonKeys(node); + var s = sorted!.ToJsonString(); + Assert.Equal("{\"a\":{\"x\":1,\"y\":2},\"b\":1}", s); + } + + [Fact] + public void Normalize_IsIdempotent() + { + var input = "{\"b\":2.0000001,\"a\":\"2026-04-07T00:00:00Z\",\"id\":\"550e8400-e29b-41d4-a716-446655440000\"}"; + var first = Normalizer.Normalize(input, "default"); + var second = Normalizer.Normalize(first.Output, "default"); + Assert.Equal(first.Output, second.Output); + } + + [Fact] + public void Normalize_AppliesAllDefaultRules() + { + var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}"; + var r = Normalizer.Normalize(input, "default"); + Assert.Equal(5, r.Log.Count); + var ids = r.Log.Select(l => l.RuleId).ToList(); + Assert.Contains("strip_timestamps", ids); + Assert.Contains("mask_guids", ids); + Assert.Contains("normalize_paths", ids); + Assert.Contains("round_floats", ids); + Assert.Contains("sort_json_keys", ids); + } +}