using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using RealCV.Application.Models; using RealCV.Infrastructure.Services; namespace RealCV.Tests.Services; public class TimelineAnalyserServiceTests { private readonly TimelineAnalyserService _sut; private readonly Mock> _loggerMock; public TimelineAnalyserServiceTests() { _loggerMock = new Mock>(); _sut = new TimelineAnalyserService(_loggerMock.Object); } #region Empty and Single Entry Tests [Fact] public void Analyse_WithEmptyEmploymentList_ReturnsEmptyResults() { // Arrange var employmentHistory = new List(); // Act var result = _sut.Analyse(employmentHistory); // Assert result.TotalGapMonths.Should().Be(0); result.TotalOverlapMonths.Should().Be(0); result.Gaps.Should().BeEmpty(); result.Overlaps.Should().BeEmpty(); } [Fact] public void Analyse_WithSingleEmploymentEntry_ReturnsNoGapsOrOverlaps() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.TotalGapMonths.Should().Be(0); result.TotalOverlapMonths.Should().Be(0); result.Gaps.Should().BeEmpty(); result.Overlaps.Should().BeEmpty(); } #endregion #region Gap Detection Tests [Fact] public void Analyse_WithTwoConsecutiveJobs_ReturnsNoGaps() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 12, 31)), CreateEmployment("Company B", new DateOnly(2021, 1, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.TotalGapMonths.Should().Be(0); result.Gaps.Should().BeEmpty(); } [Fact] public void Analyse_WithThreeMonthGap_DetectsGapCorrectly() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateEmployment("Company B", new DateOnly(2020, 10, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().HaveCount(1); result.Gaps[0].StartDate.Should().Be(new DateOnly(2020, 6, 30)); result.Gaps[0].EndDate.Should().Be(new DateOnly(2020, 10, 1)); result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(3); result.TotalGapMonths.Should().BeGreaterThanOrEqualTo(3); } [Fact] public void Analyse_WithLargeGap_DetectsGapWithCorrectMonths() { // Arrange - 6 month gap var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 15)), CreateEmployment("Company B", new DateOnly(2021, 1, 15), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().HaveCount(1); result.TotalGapMonths.Should().BeGreaterThanOrEqualTo(6); } [Fact] public void Analyse_WithSmallGapUnderThreeMonths_DoesNotFlagIt() { // Arrange - 2 month gap (should not be flagged) var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateEmployment("Company B", new DateOnly(2020, 8, 15), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().BeEmpty(); result.TotalGapMonths.Should().Be(0); } [Fact] public void Analyse_WithOneMonthGap_DoesNotFlagIt() { // Arrange - 1 month gap var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateEmployment("Company B", new DateOnly(2020, 8, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().BeEmpty(); result.TotalGapMonths.Should().Be(0); } #endregion #region Overlap Detection Tests [Fact] public void Analyse_WithOverlappingJobsMoreThanTwoMonths_DetectsOverlap() { // Arrange - 4 month overlap var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 8, 31)), CreateEmployment("Company B", new DateOnly(2020, 5, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().HaveCount(1); result.Overlaps[0].Company1.Should().Be("Company A"); result.Overlaps[0].Company2.Should().Be("Company B"); result.Overlaps[0].Months.Should().BeGreaterThan(2); result.TotalOverlapMonths.Should().BeGreaterThan(2); } [Fact] public void Analyse_WithOneMonthTransitionOverlap_DoesNotFlagIt() { // Arrange - 1 month overlap (transition period, should be allowed) var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 15)), CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().BeEmpty(); result.TotalOverlapMonths.Should().Be(0); } [Fact] public void Analyse_WithTwoMonthTransitionOverlap_DoesNotFlagIt() { // Arrange - 2 month overlap (transition period, should be allowed) var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 7, 31)), CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().BeEmpty(); result.TotalOverlapMonths.Should().Be(0); } [Fact] public void Analyse_WithThreeMonthOverlap_DetectsOverlap() { // Arrange - 3 month overlap (exceeds 2 month allowed period) var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 8, 31)), CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().HaveCount(1); result.Overlaps[0].Months.Should().BeGreaterThan(2); } #endregion #region Mixed Scenarios Tests [Fact] public void Analyse_WithMultipleJobsMixedGapsAndOverlaps_DetectsAll() { // Arrange var employmentHistory = new List { // Job 1: Jan 2019 - Dec 2019 CreateEmployment("Company A", new DateOnly(2019, 1, 1), new DateOnly(2019, 12, 31)), // Job 2: June 2020 - Dec 2020 (6 month gap after Job 1 - should be detected) CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2020, 12, 31)), // Job 3: Sep 2020 - June 2021 (4 month overlap with Job 2 - should be detected) CreateEmployment("Company C", new DateOnly(2020, 9, 1), new DateOnly(2021, 6, 30)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().HaveCount(1); result.Gaps[0].StartDate.Should().Be(new DateOnly(2019, 12, 31)); result.Gaps[0].EndDate.Should().Be(new DateOnly(2020, 6, 1)); result.Overlaps.Should().HaveCount(1); result.Overlaps[0].Company1.Should().Be("Company B"); result.Overlaps[0].Company2.Should().Be("Company C"); } [Fact] public void Analyse_WithMultipleGaps_SumsTotalGapMonths() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2019, 1, 1), new DateOnly(2019, 6, 30)), // 4 month gap CreateEmployment("Company B", new DateOnly(2019, 11, 1), new DateOnly(2020, 4, 30)), // 5 month gap CreateEmployment("Company C", new DateOnly(2020, 10, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().HaveCount(2); result.TotalGapMonths.Should().BeGreaterThanOrEqualTo(8); } [Fact] public void Analyse_WithMultipleOverlaps_SumsTotalOverlapMonths() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 9, 30)), CreateEmployment("Company B", new DateOnly(2020, 5, 1), new DateOnly(2021, 3, 31)), CreateEmployment("Company C", new DateOnly(2020, 12, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().HaveCountGreaterThan(0); result.TotalOverlapMonths.Should().BeGreaterThan(0); } #endregion #region Null Date Handling Tests [Fact] public void Analyse_WithAllNullStartDates_ReturnsEmptyResults() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", null, new DateOnly(2020, 12, 31)), CreateEmployment("Company B", null, new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.TotalGapMonths.Should().Be(0); result.TotalOverlapMonths.Should().Be(0); result.Gaps.Should().BeEmpty(); result.Overlaps.Should().BeEmpty(); } [Fact] public void Analyse_WithSomeNullStartDates_FiltersThemOut() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateEmployment("Company B", null, new DateOnly(2020, 12, 31)), CreateEmployment("Company C", new DateOnly(2021, 1, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert // Company B is filtered out, gap between A and C is 6 months result.Gaps.Should().HaveCount(1); result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(6); } [Fact] public void Analyse_WithMixedNullDates_HandlesGracefully() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", null, null), CreateEmployment("Company B", new DateOnly(2020, 1, 1), new DateOnly(2020, 12, 31)), CreateEmployment("Company C", null, new DateOnly(2021, 6, 30)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert - only Company B has valid start date, so no gaps/overlaps possible result.Gaps.Should().BeEmpty(); result.Overlaps.Should().BeEmpty(); } #endregion #region Current Job (No End Date) Tests [Fact] public void Analyse_WithCurrentJobNoEndDate_UsesTodayAsEndDate() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateCurrentEmployment("Company B", new DateOnly(2020, 7, 1)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().BeEmpty(); result.TotalGapMonths.Should().Be(0); } [Fact] public void Analyse_WithCurrentJobOverlappingPastJob_DetectsOverlap() { // Arrange - Current job started while still at previous job var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31)), CreateCurrentEmployment("Company B", new DateOnly(2024, 6, 1)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().HaveCount(1); result.Overlaps[0].Company1.Should().Be("Company A"); result.Overlaps[0].Company2.Should().Be("Company B"); result.Overlaps[0].Months.Should().BeGreaterThan(2); } [Fact] public void Analyse_WithTwoCurrentJobs_DetectsLargeOverlap() { // Arrange - Two concurrent current positions (unusual but valid scenario) var employmentHistory = new List { CreateCurrentEmployment("Company A", new DateOnly(2023, 1, 1)), CreateCurrentEmployment("Company B", new DateOnly(2023, 6, 1)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert - Should detect substantial overlap since both run to today result.Overlaps.Should().HaveCount(1); result.TotalOverlapMonths.Should().BeGreaterThan(2); } [Fact] public void Analyse_WithGapBeforeCurrentJob_DetectsGap() { // Arrange var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2023, 1, 1), new DateOnly(2023, 6, 30)), CreateCurrentEmployment("Company B", new DateOnly(2024, 1, 1)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().HaveCount(1); result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(6); } #endregion #region Edge Cases and Ordering Tests [Fact] public void Analyse_WithUnsortedEmployment_SortsByStartDate() { // Arrange - Jobs provided out of order var employmentHistory = new List { CreateEmployment("Company C", new DateOnly(2022, 1, 1), new DateOnly(2022, 12, 31)), CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateEmployment("Company B", new DateOnly(2021, 1, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert - Should detect gap between A (ends June 2020) and B (starts Jan 2021) result.Gaps.Should().HaveCount(1); result.Gaps[0].StartDate.Should().Be(new DateOnly(2020, 6, 30)); result.Gaps[0].EndDate.Should().Be(new DateOnly(2021, 1, 1)); } [Fact] public void Analyse_WithSameStartDate_HandlesCorrectly() { // Arrange - Two jobs starting on same date var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 12, 31)), CreateEmployment("Company B", new DateOnly(2020, 1, 1), new DateOnly(2020, 8, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert - Should detect overlap result.Overlaps.Should().HaveCount(1); } [Fact] public void Analyse_WithNullList_ThrowsArgumentNullException() { // Arrange List? employmentHistory = null; // Act var act = () => _sut.Analyse(employmentHistory!); // Assert act.Should().Throw(); } [Fact] public void Analyse_WithExactlyThreeMonthGap_DetectsGap() { // Arrange - Exactly 3 month gap (boundary condition) var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 3, 31)), CreateEmployment("Company B", new DateOnly(2020, 7, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().HaveCount(1); result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(3); } [Fact] public void Analyse_WithJobEndingSameDayNextStarts_NoGap() { // Arrange - Seamless transition var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), CreateEmployment("Company B", new DateOnly(2020, 6, 30), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Gaps.Should().BeEmpty(); } [Fact] public void Analyse_WithJobEndingAfterNextStarts_DetectsOverlapIfSignificant() { // Arrange - Job ends after next starts by more than 2 months var employmentHistory = new List { CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 10, 31)), CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) }; // Act var result = _sut.Analyse(employmentHistory); // Assert result.Overlaps.Should().HaveCount(1); result.Overlaps[0].Months.Should().BeGreaterThan(2); } #endregion #region Helper Methods private static EmploymentEntry CreateEmployment(string companyName, DateOnly? startDate, DateOnly? endDate) { return new EmploymentEntry { CompanyName = companyName, JobTitle = "Software Developer", StartDate = startDate, EndDate = endDate, IsCurrent = false }; } private static EmploymentEntry CreateCurrentEmployment(string companyName, DateOnly startDate) { return new EmploymentEntry { CompanyName = companyName, JobTitle = "Software Developer", StartDate = startDate, EndDate = null, IsCurrent = true }; } #endregion }