On some locales, odd strings would parse as valid full dates, causing relative dates to be incorrectly calculated. This commit fixes the issues and revisits the DST aware calculations with more tests.
292 lines
11 KiB
C#
292 lines
11 KiB
C#
|
|
// Copyright (C) 2025, The Duplicati Team
|
|
// https://duplicati.com, hello@duplicati.com
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
// copy of this software and associated documentation files (the "Software"),
|
|
// to deal in the Software without restriction, including without limitation
|
|
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
// and/or sell copies of the Software, and to permit persons to whom the
|
|
// Software is furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
// DEALINGS IN THE SOFTWARE.
|
|
|
|
using System;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
using Duplicati.Library.Utility;
|
|
using NUnit.Framework;
|
|
|
|
namespace Duplicati.UnitTest;
|
|
|
|
public class TimeparserTests : BasicSetupHelper
|
|
{
|
|
[Category("Utility")]
|
|
[TestCase("0s", 0)]
|
|
[TestCase("10s", 10000)]
|
|
[TestCase("1m", 60000)]
|
|
[TestCase("2h", 7200000)]
|
|
[TestCase("1D", 86400000)]
|
|
[TestCase("1W", 604800000)]
|
|
[Category("Utility")]
|
|
public void TestParseTimeSpan_EnglishLocale(string input, long expectedMilliseconds)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentCulture = new CultureInfo("en-US");
|
|
var ts = Timeparser.ParseTimeSpan(input);
|
|
Assert.AreEqual(expectedMilliseconds, (long)ts.TotalMilliseconds);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("fr-CA", "0s", 0)]
|
|
[TestCase("fr-CA", "10s", 10000)]
|
|
[TestCase("de-DE", "5m", 300000)]
|
|
[TestCase("en-GB", "2h", 7200000)]
|
|
[TestCase("sv-SE", "1D", 86400000)]
|
|
public void TestParseTimeSpan_LocaleVariations(string locale, string input, long expectedMilliseconds)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentCulture = new CultureInfo(locale);
|
|
var ts = Timeparser.ParseTimeSpan(input);
|
|
Assert.AreEqual(expectedMilliseconds, (long)ts.TotalMilliseconds);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
[Category("Utility")]
|
|
public void TestParseTimeSpan_InvalidInput_Throws()
|
|
{
|
|
Assert.Throws<Exception>(() => Timeparser.ParseTimeSpan("xyz"));
|
|
}
|
|
|
|
[Test]
|
|
[Category("Utility")]
|
|
public void TestParseTimeSpan_EmptyString()
|
|
{
|
|
var ts = Timeparser.ParseTimeSpan("");
|
|
Assert.AreEqual(TimeSpan.Zero, ts);
|
|
}
|
|
|
|
[Test]
|
|
[Category("Utility")]
|
|
public void TestParseTimeSpan_Now()
|
|
{
|
|
var now = DateTime.Now;
|
|
var parsed = Timeparser.ParseTimeInterval("now", now);
|
|
Assert.That((parsed - now).TotalSeconds, Is.LessThan(1));
|
|
}
|
|
|
|
[Test]
|
|
[Category("Utility")]
|
|
public void TestParseTimeInterval_WithOffsetAndNegate()
|
|
{
|
|
var offset = new DateTime(2020, 1, 1);
|
|
var result = Timeparser.ParseTimeInterval("10s", offset, negate: true);
|
|
Assert.AreEqual(offset.AddSeconds(-10), result);
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("0s", 0)]
|
|
[TestCase("15m", 900000)]
|
|
[TestCase("1h30m", 5400000)]
|
|
public void TestParseTimeInterval_SpecialCases(string input, long expectedMilliseconds)
|
|
{
|
|
var baseTime = new DateTime(2023, 1, 1, 0, 0, 0);
|
|
var result = Timeparser.ParseTimeInterval(input, baseTime);
|
|
var diff = (long)(result - baseTime).TotalMilliseconds;
|
|
Assert.AreEqual(expectedMilliseconds, diff);
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("0s", "fr-CA")]
|
|
[TestCase("0s", "de-DE")]
|
|
public void TestParseTimeInterval_LocaleSensitiveAmbiguity(string input, string locale)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentCulture = new CultureInfo(locale);
|
|
var baseTime = new DateTime(2023, 1, 1);
|
|
var result = Timeparser.ParseTimeInterval(input, baseTime);
|
|
Assert.AreEqual(baseTime, result); // 0s should not change the time
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("en-US")]
|
|
[TestCase("fr-CA")]
|
|
[TestCase("de-DE")]
|
|
public void DSTAware_AddHour_SpringForward_ShouldSkipMissingHour(string culture)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
try
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var start = new DateTime(2024, 3, 10, 6, 30, 0, DateTimeKind.Utc); // UTC 1:30 AM EST
|
|
var result = Timeparser.DSTAwareParseTimeInterval("1h", start, tz);
|
|
var expected = new DateTime(2024, 3, 10, 7, 30, 0, DateTimeKind.Utc); // UTC 3:30 AM EDT
|
|
Assert.AreEqual(expected, result);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("en-US")]
|
|
[TestCase("fr-CA")]
|
|
[TestCase("de-DE")]
|
|
public void DSTAware_AddHour_FallBack_ShouldHandleAmbiguousTime(string culture)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
try
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var start = new DateTime(2024, 11, 3, 5, 30, 0, DateTimeKind.Utc); // 1:30 AM EDT
|
|
var result = Timeparser.DSTAwareParseTimeInterval("1h", start, tz);
|
|
var expected = new DateTime(2024, 11, 3, 6, 30, 0, DateTimeKind.Utc); // 1:30 AM EST
|
|
Assert.AreEqual(expected, result);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("en-US")]
|
|
[TestCase("fr-CA")]
|
|
[TestCase("de-DE")]
|
|
public void DSTAware_AddMultipleUnits_Combined(string culture)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
try
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var start = new DateTime(2024, 3, 10, 4, 0, 0, DateTimeKind.Utc); // March 9, 11 PM EST
|
|
var result = Timeparser.DSTAwareParseTimeInterval("1D2h", start, tz);
|
|
var expected = new DateTime(2024, 3, 11, 6, 0, 0, DateTimeKind.Utc); // March 11, 1 AM EDT
|
|
Assert.AreEqual(expected, result);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("en-US")]
|
|
[TestCase("fr-CA")]
|
|
[TestCase("de-DE")]
|
|
public void DSTAware_KeepTime_SpringForward_ShouldKeepClockTime(string culture)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
try
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var start = new DateTime(2024, 3, 9, 6, 30, 0, DateTimeKind.Utc); // March 9, 1:30 AM EST
|
|
var result = Timeparser.DSTAwareParseTimeInterval("1D", start, tz);
|
|
var expected = new DateTime(2024, 3, 10, 6, 30, 0, DateTimeKind.Utc); // March 10, 1:30 AM EDT
|
|
Assert.AreEqual(expected, result);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Category("Utility")]
|
|
[TestCase("en-US")]
|
|
[TestCase("fr-CA")]
|
|
[TestCase("de-DE")]
|
|
public void DSTAware_KeepTime_FallBack_ShouldKeepClockTime(string culture)
|
|
{
|
|
var originalCulture = CultureInfo.CurrentCulture;
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
try
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var start = new DateTime(2024, 11, 2, 5, 30, 0, DateTimeKind.Utc); // Nov 2, 1:30 AM EDT
|
|
var result = Timeparser.DSTAwareParseTimeInterval("1D", start, tz);
|
|
var expected = new DateTime(2024, 11, 3, 5, 30, 0, DateTimeKind.Utc); // Nov 3, 1:30 AM EST
|
|
Assert.AreEqual(expected, result);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = originalCulture;
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void SpringForward_Add1Hour_ShouldNeverReturnNonexistentLocalTime()
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var rawStart = new DateTime(2024, 3, 10, 6, 30, 0, DateTimeKind.Utc); // 1:30 AM EST (UTC)
|
|
|
|
var resultUtc = Timeparser.DSTAwareParseTimeInterval("1h", rawStart, tz);
|
|
|
|
// Convert the result back to local time in the given time zone
|
|
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(resultUtc, tz);
|
|
|
|
// Verify the returned local time is not within the invalid DST range
|
|
Assert.IsFalse(tz.IsInvalidTime(resultLocal), $"Returned a non-existent local time: {resultLocal}");
|
|
}
|
|
|
|
[Test]
|
|
public void MultipleUnits_SpringForward_ShouldAvoidInvalidLocalTime()
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var rawStart = new DateTime(2024, 3, 10, 4, 30, 0, DateTimeKind.Utc); // 11:30 PM March 9 EST (UTC)
|
|
|
|
var resultUtc = Timeparser.DSTAwareParseTimeInterval("1D2h", rawStart, tz);
|
|
|
|
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(resultUtc, tz);
|
|
Assert.IsFalse(tz.IsInvalidTime(resultLocal), $"Returned a non-existent local time: {resultLocal}");
|
|
}
|
|
|
|
[Test]
|
|
public void AmbiguousTime_FallBack_ShouldReturnValidLocalTime()
|
|
{
|
|
var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
|
|
var rawStart = new DateTime(2024, 11, 3, 5, 30, 0, DateTimeKind.Utc); // 1:30 AM EDT (UTC)
|
|
|
|
var resultUtc = Timeparser.DSTAwareParseTimeInterval("1h", rawStart, tz);
|
|
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(resultUtc, tz);
|
|
|
|
// This will be an ambiguous time but still valid
|
|
Assert.IsTrue(tz.IsAmbiguousTime(resultLocal) || !tz.IsInvalidTime(resultLocal),
|
|
$"Returned an invalid or ambiguous local time incorrectly: {resultLocal}");
|
|
}
|
|
}
|