using System.Globalization; using MeterVault.App.Analysis; namespace MeterVault.Integration.Tests.Analysis; /// The CSV of the analysis table (D-55): invariant numbers, local ISO bounds, empty unknowns, safe text. Pure. public sealed class AnalysisCsvWriterTests { private static readonly TimeSpan Cet = TimeSpan.FromHours(1); [Fact] public void Rows_are_written_with_invariant_numbers_whatever_the_culture() { var row = new AnalysisCsvRow( "m12", "Zähler Haus", "Consumption", "kWh", new DateTimeOffset(2024, 1, 1, 0, 0, 0, Cet), new DateTimeOffset(2024, 2, 1, 0, 0, 0, Cet), "Europe/Berlin", 1234.5, "Available", "Measured|Estimated", 370.35, "Priced", "EUR", 0.1 + 0.2); var previous = CultureInfo.CurrentCulture; string csv; try { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de"); csv = AnalysisCsvWriter.Write([row]); } finally { CultureInfo.CurrentCulture = previous; } var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal( "series_id,series_name,kind,unit,bucket_start,bucket_end,timezone,value,status,provenance,cost,cost_status,currency,comparison_value", lines[0]); Assert.Equal( "m12,Zähler Haus,Consumption,kWh,2024-01-01T00:00:00+01:00,2024-02-01T00:00:00+01:00,Europe/Berlin,1234.5,Available,Measured|Estimated,370.35,Priced,EUR,0.30000000000000004", lines[1]); } [Fact] public void Unknown_values_are_empty_cells_never_zero() { var row = new AnalysisCsvRow( "t3:use:kWh", "Strom · Total use", "Consumption", "kWh", new DateTimeOffset(2026, 9, 1, 0, 0, 0, TimeSpan.FromHours(2)), new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)), "Europe/Berlin", null, "Missing", string.Empty, null, null, null, double.NaN); var line = AnalysisCsvWriter.Write([row]).Split("\r\n")[1]; Assert.Equal( "t3:use:kWh,Strom · Total use,Consumption,kWh,2026-09-01T00:00:00+02:00,2026-09-19T14:37:00+02:00,Europe/Berlin,,Missing,,,,,", line); } [Theory] [InlineData("plain", "plain")] [InlineData("a,b", "\"a,b\"")] [InlineData("say \"hi\"", "\"say \"\"hi\"\"\"")] [InlineData("two\nlines", "\"two\nlines\"")] [InlineData("", "")] [InlineData(null, "")] public void Fields_are_quoted_as_rfc_4180_asks(string? field, string expected) => Assert.Equal(expected, AnalysisCsvWriter.Escape(field)); [Theory] [InlineData("=HYPERLINK(\"x\")", "'=HYPERLINK(\"x\")")] [InlineData("+1", "'+1")] [InlineData("-Solar", "'-Solar")] [InlineData("@home", "'@home")] [InlineData("Solar 1", "Solar 1")] public void User_text_cannot_become_a_spreadsheet_formula(string name, string expected) => Assert.Equal(expected, AnalysisCsvWriter.Text(name)); [Fact] public async Task The_async_writer_writes_the_same_text() { var row = new AnalysisCsvRow( "portfolio", "All energy types", "Cost", "EUR", new DateTimeOffset(2024, 1, 1, 0, 0, 0, Cet), new DateTimeOffset(2024, 2, 1, 0, 0, 0, Cet), "Europe/Berlin", -12.5, "Available", string.Empty, -12.5, "Priced", "EUR", null); await using var writer = new StringWriter(CultureInfo.InvariantCulture); await AnalysisCsvWriter.WriteAsync(writer, [row]); Assert.Equal(AnalysisCsvWriter.Write([row]), writer.ToString()); Assert.Contains(",-12.5,Available,,-12.5,Priced,EUR,", writer.ToString(), StringComparison.Ordinal); } }