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));
}
}