using System.Globalization; using MeterVault.Core.Analysis; using static MeterVault.Core.Tests.Analysis.AnalysisClock; namespace MeterVault.Core.Tests.Analysis; /// /// URL tokens are stable invariant identifiers (D-02, D-46): what a German browser writes, an English one /// reads. Parsing never throws, so a hand-edited or stale link falls back to the page default instead of /// breaking the page. /// public sealed class AnalysisTokensTests { [Theory] [InlineData(PeriodPreset.MonthToDate, "mtd")] [InlineData(PeriodPreset.LastMonth, "last-month")] [InlineData(PeriodPreset.YearToDate, "ytd")] [InlineData(PeriodPreset.PreviousYear, "prev-year")] [InlineData(PeriodPreset.Last12Months, "12m")] [InlineData(PeriodPreset.Last24Months, "24m")] [InlineData(PeriodPreset.AllHistory, "all")] [InlineData(PeriodPreset.Custom, "custom")] public void Every_period_preset_has_its_documented_token_and_parses_back(PeriodPreset preset, string token) { Assert.Equal(token, AnalysisTokens.Format(preset)); Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed)); Assert.Equal(preset, parsed); } [Theory] [InlineData(BucketSize.Auto, "auto")] [InlineData(BucketSize.Day, "day")] [InlineData(BucketSize.Week, "week")] [InlineData(BucketSize.Month, "month")] [InlineData(BucketSize.Year, "year")] public void Every_bucket_size_has_its_documented_token_and_parses_back(BucketSize size, string token) { Assert.Equal(token, AnalysisTokens.Format(size)); Assert.True(AnalysisTokens.TryParseBucket(token, out var parsed)); Assert.Equal(size, parsed); } [Fact] public void Every_enum_value_has_a_token_so_no_state_is_unlinkable() { Assert.All(Enum.GetValues(), p => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(p)))); Assert.All(Enum.GetValues(), b => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(b)))); } [Theory] [InlineData("none", ComparisonKind.None, null)] [InlineData("prev-period", ComparisonKind.PreviousPeriod, null)] [InlineData("prev-year", ComparisonKind.PreviousYear, null)] [InlineData("year:2025", ComparisonKind.Year, 2025)] [InlineData("year:1997", ComparisonKind.Year, 1997)] public void Comparison_tokens_round_trip(string token, ComparisonKind kind, int? year) { Assert.True(AnalysisTokens.TryParseComparison(token, out var request)); Assert.Equal(new ComparisonRequest(kind, year), request); Assert.Equal(token, AnalysisTokens.Format(request)); } [Fact] public void Parsing_none_returns_the_shared_none_request() { Assert.True(AnalysisTokens.TryParseComparison("none", out var request)); Assert.Same(ComparisonRequest.None, request); } [Theory] [InlineData("MTD", PeriodPreset.MonthToDate)] [InlineData(" 12m ", PeriodPreset.Last12Months)] [InlineData("Last-Month", PeriodPreset.LastMonth)] public void Period_tokens_are_read_regardless_of_case_and_surrounding_blanks(string token, PeriodPreset expected) { Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed)); Assert.Equal(expected, parsed); } [Theory] [InlineData("previous-year", ComparisonKind.PreviousYear)] [InlineData("previous-period", ComparisonKind.PreviousPeriod)] [InlineData("PREV-YEAR", ComparisonKind.PreviousYear)] [InlineData(" Year:2024 ", ComparisonKind.Year)] public void Spelled_out_comparison_aliases_are_accepted_but_never_written(string token, ComparisonKind expected) { Assert.True(AnalysisTokens.TryParseComparison(token, out var request)); Assert.Equal(expected, request.Kind); Assert.DoesNotContain("previous", AnalysisTokens.Format(request), StringComparison.Ordinal); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("month-to-date")] [InlineData("13m")] [InlineData("today")] [InlineData("mtd;drop table")] [InlineData("mtd2")] public void Unknown_period_tokens_are_rejected_without_throwing(string? token) { Assert.False(AnalysisTokens.TryParsePeriod(token, out _)); Assert.False(AnalysisTokens.TryParseBucket(token, out _)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("year")] [InlineData("year:")] [InlineData("year:25")] [InlineData("year:20250")] [InlineData("year:-202")] [InlineData("year:+202")] [InlineData("year: 2025")] [InlineData("year:2025.0")] [InlineData("year:2025")] // full-width digits [InlineData("year:1899")] [InlineData("year:2300")] [InlineData("year:abcd")] [InlineData("last-year")] public void Malformed_or_out_of_range_comparison_tokens_are_rejected_without_throwing(string? token) { Assert.False(AnalysisTokens.TryParseComparison(token, out var request)); Assert.Null(request); } [Fact] public void A_long_garbage_token_is_rejected_without_throwing() { var garbage = new string('x', 100_000); Assert.False(AnalysisTokens.TryParsePeriod(garbage, out _)); Assert.False(AnalysisTokens.TryParseComparison("year:" + garbage, out _)); Assert.False(AnalysisTokens.TryParseDate(garbage, out _)); } [Theory] [InlineData("2026-09-19", 2026, 9, 19)] [InlineData("2028-02-29", 2028, 2, 29)] [InlineData("1900-01-01", 1900, 1, 1)] [InlineData("2299-12-31", 2299, 12, 31)] public void Iso_dates_parse_exactly(string token, int year, int month, int day) { Assert.True(AnalysisTokens.TryParseDate(token, out var date)); Assert.Equal(Day(year, month, day), date); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("2026-9-19")] [InlineData("19.09.2026")] [InlineData("09/19/2026")] [InlineData("2026-02-29")] [InlineData("2026-13-01")] [InlineData("2026-09-19T00:00")] [InlineData("1899-12-31")] [InlineData("2300-01-01")] [InlineData("9999-12-31")] public void Dates_in_any_other_layout_or_outside_the_supported_range_are_rejected(string? token) { Assert.False(AnalysisTokens.TryParseDate(token, out var date)); Assert.Equal(default(DateOnly), date); } [Fact] public void Dates_are_written_invariantly_whatever_the_reader_culture() { var saved = CultureInfo.CurrentCulture; try { foreach (var culture in new[] { "de-DE", "ar-SA", "th-TH" }) { CultureInfo.CurrentCulture = new CultureInfo(culture); Assert.Equal("2026-09-19", AnalysisTokens.FormatDate(Day(2026, 9, 19))); Assert.Equal("year:2025", AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year, 2025))); Assert.True(AnalysisTokens.TryParseDate("2026-09-19", out var parsed)); Assert.Equal(Day(2026, 9, 19), parsed); } } finally { CultureInfo.CurrentCulture = saved; } } [Fact] public void A_custom_range_needs_both_dates_in_order() { Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-09-30", out var first, out var last)); Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 30)), (first, last)); Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-19", "2026-09-19", out _, out _)); Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-30", "2026-09-01", out _, out _)); Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", null, out _, out _)); Assert.False(AnalysisTokens.TryParseCustomRange(null, "2026-09-30", out _, out _)); Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", "30.09.2026", out _, out _)); } [Fact] public void A_parsed_custom_range_resolves_without_error() { Assert.True(AnalysisTokens.TryParsePeriod("custom", out var preset)); Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-12-31", out var first, out var last)); var period = PeriodResolver.Resolve(preset, first, last, At(Berlin, 2026, 9, 19, 14, 37), Berlin); Assert.True(period.ExtendsPastNow); Assert.Equal("2026-12-31", AnalysisTokens.FormatDate(period.LastDay)); } [Fact] public void Formatting_a_year_comparison_without_a_year_is_a_programming_error() { Assert.Throws(() => AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year))); } [Fact] public void Formatting_an_undefined_enum_value_is_a_programming_error() { Assert.Throws(() => AnalysisTokens.Format((PeriodPreset)99)); Assert.Throws(() => AnalysisTokens.Format((BucketSize)99)); } }