Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ jobs:
- name: Build solution
run: dotnet build Timetracker.slnx -c Release

- name: Test
run: dotnet test Timetracker.slnx -c Release --no-build

- name: Pack (validate packaging)
run: dotnet pack Timetracker -c Release -o ./nupkg
89 changes: 89 additions & 0 deletions Timetracker.Tests/AddValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Timetracker.Options;
using Timetracker.Validators;

namespace Timetracker.Tests;

public class AddValidatorTests
{
private static readonly string[] Activities = ["DEVELOPMENT", "TESTING"];

private static AddOptions ValidOptions() => new()
{
ActivityDate = "today",
WorkItemId = 12345,
ActivityLength = 2m,
ActivityType = "Development",
ActivityStartHour = "09:00",
ActivityComment = null,
};

[Fact]
public void HappyPath_IsValid()
{
var result = new AddValidator(Activities).Validate(ValidOptions());

Assert.True(result.IsValid);
}

[Theory]
[InlineData("2026-06-15")]
[InlineData("tomorrow")]
[InlineData("")]
public void InvalidDate_Fails(string date)
{
var opts = ValidOptions();
opts.ActivityDate = date;

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonPositiveWorkItem_Fails(int id)
{
var opts = ValidOptions();
opts.WorkItemId = id;

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Fact]
public void NonPositiveLength_Fails()
{
var opts = ValidOptions();
opts.ActivityLength = 0m;

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Fact]
public void UnknownActivityType_Fails()
{
var opts = ValidOptions();
opts.ActivityType = "Meeting";

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Theory]
[InlineData("24:00")]
[InlineData("9:60")]
[InlineData("noon")]
public void InvalidStartHour_Fails(string hour)
{
var opts = ValidOptions();
opts.ActivityStartHour = hour;

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Fact]
public void ShortComment_Fails()
{
var opts = ValidOptions();
opts.ActivityComment = "ab";

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}
}
56 changes: 56 additions & 0 deletions Timetracker.Tests/ConfigValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Timetracker.Options;
using Timetracker.Validators;

namespace Timetracker.Tests;

// Only the rules that do not depend on ConfigService.ConfigExists() are covered here.
// The first-time-setup credential rules touch the real config store and are deferred to
// Phase 2, once that existence check can be injected.
public class ConfigValidatorTests
{
[Fact]
public void NoOptions_Fails()
{
var result = new ConfigValidator().Validate(new ConfigOptions());

Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("at least one option"));
}

[Theory]
[InlineData("minimal")]
[InlineData("square")]
[InlineData("markdown")]
[InlineData("MARKDOWN")]
public void ValidBorder_IsValid(string border)
{
// Show bypasses the first-time credential rules, isolating the border rule.
var opts = new ConfigOptions { Show = true, Border = border };

Assert.True(new ConfigValidator().Validate(opts).IsValid);
}

[Fact]
public void InvalidBorder_Fails()
{
var opts = new ConfigOptions { Show = true, Border = "fancy" };

Assert.False(new ConfigValidator().Validate(opts).IsValid);
}

[Fact]
public void NonHttpsUrl_Fails()
{
var opts = new ConfigOptions { Show = true, TimetrackerUrl = "http://acme.timehub.7pace.com" };

Assert.False(new ConfigValidator().Validate(opts).IsValid);
}

[Fact]
public void HttpsUrl_IsValid()
{
var opts = new ConfigOptions { Show = true, TimetrackerUrl = "https://acme.timehub.7pace.com" };

Assert.True(new ConfigValidator().Validate(opts).IsValid);
}
}
17 changes: 17 additions & 0 deletions Timetracker.Tests/FakePeriodOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Timetracker.Options;

namespace Timetracker.Tests;

/// <summary>Mutable <see cref="IPeriodOptions"/> for exercising <c>PeriodResolver</c>.</summary>
internal sealed class FakePeriodOptions : IPeriodOptions
{
public string From { get; init; }
public string To { get; init; }
public string Period { get; init; }
public bool Today { get; init; }
public bool Yesterday { get; init; }
public bool Week { get; init; }
public bool LastWeek { get; init; }
public bool Month { get; init; }
public bool LastMonth { get; init; }
}
179 changes: 179 additions & 0 deletions Timetracker.Tests/PeriodResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using Timetracker.Utils;

namespace Timetracker.Tests;

public class PeriodResolverTests
{
// --- Error paths -------------------------------------------------------

[Fact]
public void MutuallyExclusiveFlags_Fail()
{
var opts = new FakePeriodOptions { Today = true, Week = true };

var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);

Assert.False(ok);
Assert.Contains("mutually exclusive", error);
}

[Fact]
public void PeriodPlusExplicitPeriod_Fail()
{
var opts = new FakePeriodOptions { Month = true, Period = "2026/06" };

var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);

Assert.False(ok);
Assert.Contains("mutually exclusive", error);
}

[Fact]
public void ShortcutWithFromOrTo_Fail()
{
var opts = new FakePeriodOptions { Week = true, From = "2026/06/01" };

var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);

Assert.False(ok);
Assert.Contains("shortcuts cannot be used", error);
}

[Fact]
public void PeriodWithFromOrTo_Fail()
{
var opts = new FakePeriodOptions { Period = "2026/06", To = "2026/06/15" };

var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);

Assert.False(ok);
Assert.Contains("--period cannot be used", error);
}

[Theory]
[InlineData("2026-06")]
[InlineData("2026/13")]
[InlineData("June")]
[InlineData("26/06")]
public void InvalidPeriodFormat_Fail(string period)
{
var opts = new FakePeriodOptions { Period = period };

var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);

Assert.False(ok);
Assert.Contains("Invalid period format", error);
}

[Fact]
public void FromAfterTo_Fail()
{
var opts = new FakePeriodOptions { From = "2026/06/30", To = "2026/06/01" };

var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);

Assert.False(ok);
Assert.Contains("must be earlier than or equal", error);
}

// --- Success paths -----------------------------------------------------

[Fact]
public void NoOptions_DefaultsToToday()
{
var ok = PeriodResolver.TryResolve(new FakePeriodOptions(), out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(DateTime.Today, from);
Assert.Equal(DateTime.Today, to);
}

[Fact]
public void Today_ResolvesToToday()
{
var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Today = true }, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(DateTime.Today, from);
Assert.Equal(DateTime.Today, to);
}

[Fact]
public void Yesterday_ResolvesToYesterday()
{
var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Yesterday = true }, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(DateTime.Today.AddDays(-1), from);
Assert.Equal(DateTime.Today.AddDays(-1), to);
}

[Fact]
public void ExplicitRange_IsPreserved()
{
var opts = new FakePeriodOptions { From = "2026/06/10", To = "2026/06/20" };

var ok = PeriodResolver.TryResolve(opts, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(new DateTime(2026, 6, 10), from);
Assert.Equal(new DateTime(2026, 6, 20), to);
}

[Fact]
public void Period_ResolvesToWholeMonth()
{
var opts = new FakePeriodOptions { Period = "2026/02" };

var ok = PeriodResolver.TryResolve(opts, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(new DateTime(2026, 2, 1), from);
Assert.Equal(new DateTime(2026, 2, 28), to); // 2026 is not a leap year
}

// --- Time-relative invariants (independent of the actual "today") ------

[Fact]
public void Week_IsMondayToSunday()
{
var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Week = true }, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(DayOfWeek.Monday, from.DayOfWeek);
Assert.Equal(DayOfWeek.Sunday, to.DayOfWeek);
Assert.Equal(6, (to - from).Days);
}

[Fact]
public void LastWeek_IsExactlySevenDaysBeforeThisWeek()
{
PeriodResolver.TryResolve(new FakePeriodOptions { Week = true }, out var weekFrom, out var weekTo, out _);
PeriodResolver.TryResolve(new FakePeriodOptions { LastWeek = true }, out var lastFrom, out var lastTo, out _);

Assert.Equal(weekFrom.AddDays(-7), lastFrom);
Assert.Equal(weekTo.AddDays(-7), lastTo);
}

[Fact]
public void Month_CoversFirstToLastDayOfCurrentMonth()
{
var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Month = true }, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(1, from.Day);
Assert.Equal(DateTime.Today.Month, from.Month);
Assert.Equal(from.AddMonths(1).AddDays(-1), to);
}

[Fact]
public void LastMonth_CoversFirstToLastDayOfPreviousMonth()
{
var ok = PeriodResolver.TryResolve(new FakePeriodOptions { LastMonth = true }, out var from, out var to, out _);

Assert.True(ok);
Assert.Equal(1, from.Day);
Assert.Equal(from.AddMonths(1).AddDays(-1), to);
Assert.Equal(DateTime.Today.Month, from.AddMonths(1).Month);
}
}
26 changes: 26 additions & 0 deletions Timetracker.Tests/Timetracker.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../Timetracker/Timetracker.csproj" />
</ItemGroup>

</Project>
Loading
Loading