diff --git a/CodingTracker.Tests/AppControllerTests.cs b/CodingTracker.Tests/AppControllerTests.cs new file mode 100644 index 000000000..4a56cfa0e --- /dev/null +++ b/CodingTracker.Tests/AppControllerTests.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using CodeReviews.Console.CodingTracker; + +[TestFixture] +public sealed class AppControllerTests +{ + private FakeAppView _appView = null!; + private FakeCodingSessionController _codingController = null!; + private AppController _controller = null!; + + [SetUp] + public void SetUp() + { + _appView = new FakeAppView(); + _codingController = new FakeCodingSessionController(); + _controller = new AppController(_appView, _codingController); + } + + [Test] + public void Run_ViewSelected_CallsViewSessionsAndDisplaysGoodbye() + { + _appView.OptionsToReturn.Enqueue(MenuOption.View); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.Multiple(() => + { + Assert.That(_codingController.ViewSessionsCalls, Is.EqualTo(1)); + Assert.That(_appView.DisplayGoodbyeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void Run_AddSelected_CallsAddSession() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Add); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.That(_codingController.AddSessionCalls, Is.EqualTo(1)); + } + + [Test] + public void Run_EditSelected_CallsUpdateSession() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Edit); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.That(_codingController.UpdateSessionCalls, Is.EqualTo(1)); + } + + [Test] + public void Run_DeleteSelected_CallsDeleteSession() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Delete); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.That(_codingController.DeleteSessionCalls, Is.EqualTo(1)); + } + + [Test] + public void Run_CloseSelected_DisplaysGoodbyeOnly() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.Multiple(() => + { + Assert.That(_codingController.ViewSessionsCalls, Is.Zero); + Assert.That(_codingController.AddSessionCalls, Is.Zero); + Assert.That(_codingController.UpdateSessionCalls, Is.Zero); + Assert.That(_codingController.DeleteSessionCalls, Is.Zero); + Assert.That(_appView.DisplayGoodbyeCalls, Is.EqualTo(1)); + }); + } +} diff --git a/CodingTracker.Tests/CodingSessionControllerTests.cs b/CodingTracker.Tests/CodingSessionControllerTests.cs new file mode 100644 index 000000000..6aec4b208 --- /dev/null +++ b/CodingTracker.Tests/CodingSessionControllerTests.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CodeReviews.Console.CodingTracker; + +[TestFixture] +public sealed class CodingSessionControllerTests +{ + private FakeCodingSessionView _view; + private FakeCodingSessionService _service; + private CodingSessionController _controller; + + [SetUp] + public void SetUp() + { + _view = new FakeCodingSessionView(); + _service = new FakeCodingSessionService(); + _controller = new CodingSessionController(_service, _view); + } + + [Test] + public void AddSession_ManualOption_GetsTimesAndCallsServiceAdd() + { + _view.AddSessionOptionToReturn = AddSessionOption.Manual; + _view.SessionTimesToReturn = ( + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 9, 0, 0)); + + _controller.AddSession(); + + Assert.Multiple(() => + { + Assert.That(_service.AddCalled, Is.True); + Assert.That(_service.AddedStartTime, Is.EqualTo(_view.SessionTimesToReturn.StartTime)); + Assert.That(_service.AddedEndTime, Is.EqualTo(_view.SessionTimesToReturn.EndTime)); + Assert.That(_view.LastMessage, Is.EqualTo("Coding session added successfully.")); + }); + } + + [Test] + public void AddSession_TimerOption_RecordsStartAndEndAndCallsServiceAdd() + { + _view.AddSessionOptionToReturn = AddSessionOption.Stopwatch; + + _controller.AddSession(); + + Assert.Multiple(() => + { + Assert.That(_view.WaitStopwatchCalled, Is.True); + Assert.That(_service.AddCalled, Is.True); + Assert.That(_service.AddedStartTime, Is.Not.Null); + Assert.That(_service.AddedEndTime, Is.Not.Null); + Assert.That(_service.AddedStartTime, Is.LessThanOrEqualTo(_service.AddedEndTime!.Value)); + Assert.That(_view.LastMessage, Does.StartWith("Session recorded.")); + }); + } + + [Test] + public void AddSession_BackOption_DoesNotCallService() + { + _view.AddSessionOptionToReturn = AddSessionOption.Back; + + _controller.AddSession(); + + Assert.That(_service.AddCalled, Is.False); + } + + [Test] + public void AddSession_ServiceThrowsArgumentException_DisplaysError() + { + _view.AddSessionOptionToReturn = AddSessionOption.Manual; + _view.SessionTimesToReturn = ( + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 7, 0, 0)); + _service.AddExceptionToThrow = new ArgumentException("Invalid times"); + + _controller.AddSession(); + + Assert.Multiple(() => + { + Assert.That(_service.AddCalled, Is.True); + Assert.That(_view.LastError, Is.EqualTo("Invalid times")); + Assert.That(_view.DisplayMessageCount, Is.EqualTo(0)); + }); + } + + [Test] + public void ViewSessions_NoSessions_DisplaysEmptyListAndDoesNotShowFilterMenu() + { + _service.SessionsToReturn = new List(); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayedSessions.Single().Count, Is.EqualTo(0)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void ViewSessions_SessionsExist_DisplaysSessionsAndShowsFilterMenu() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayedSessions.Single().Count, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_ShowAllSelected_CallsGetAllAgain() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.FilterOptionsToReturn.Enqueue(FilterOption.ShowAll); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.GetAllCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + }); + } + + [Test] + public void ViewSessions_DaySelected_GetsDateAndCallsGetByDay() + { + DateTime requestedDate = new DateTime(2026, 7, 14); + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2026, 1, 1, 8, 0, 0), + EndTime = new DateTime(2026, 1, 1, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByDay = [ + new CodingSession + { + Id = 1, + StartTime = requestedDate, + EndTime = requestedDate.AddHours(1) + } + ]; + + _view.FilterDateToReturn = requestedDate; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Day); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.DayArgument, Is.EqualTo(requestedDate)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + }); + } + + [Test] + public void ViewSessions_WeekSelected_GetsDateAndCallsGetByWeek() + { + DateTime requestedDate = new DateTime(2026, 7, 16); + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2026, 7, 10, 8, 0, 0), + EndTime = new DateTime(2026, 7, 10, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByWeek = [ + new CodingSession + { + Id = 1, + StartTime = requestedDate, + EndTime = requestedDate.AddHours(1) + } + ]; + + _view.FilterDateToReturn = requestedDate; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Week); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.WeekArgument, Is.EqualTo(requestedDate)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1].Single().Id, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_MonthSelected_GetsMonthAndYearAndCallsGetByMonthOfYear() + { + Month month = Month.July; + int year = 2026; + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2026, 1, 10, 8, 0, 0), + EndTime = new DateTime(2026, 1, 10, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByMonth = [ + new CodingSession + { + Id = 1, + StartTime =new DateTime(year, (int)month, 1, 8, 0, 0), + EndTime = new DateTime(year, (int)month, 1, 9, 0, 0) + } + ]; + + _view.MonthToReturn = month; + _view.YearToReturn = year; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Month); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.MonthArguments, Is.EqualTo((year, (int)month))); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1].Single().Id, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_YearSelected_GetsYearAndCallsGetByYear() + { + int year = 2026; + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2025, 7, 10, 8, 0, 0), + EndTime = new DateTime(2025, 7, 10, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByYear = [ + new CodingSession + { + Id = 1, + StartTime = new DateTime(year, 1, 1, 8, 0, 0), + EndTime = new DateTime(year, 1, 1, 9, 0, 0) + } + ]; + + _view.YearToReturn = year; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Year); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.YearArgument, Is.EqualTo(year)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1].Single().Id, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_BackSelected_ReturnsWithoutFurtherServiceCalls() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.GetAllCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(1)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void UpdateSession_NoSessions_DoesNotAskForId() + { + _service.SessionsToReturn = new List(); + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + Assert.That(_view.LastMessage, Is.EqualTo("No coding sessions available to update.")); + }); + } + + [Test] + public void UpdateSession_SessionsExist_DisplaysSessionsBeforeAskingForId() + { + var session = new CodingSession + { + Id = 1, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + _service.SessionsToReturn = [session]; + _view.SessionIdToReturn = 1; + + _view.SessionTimesToReturn = + ( + new DateTime(2026, 7, 14, 10, 0, 0), + new DateTime(2026, 7, 14, 11, 0, 0) + ); + + _controller.UpdateSession(); + + Assert.That(_view.Events, Is.EqualTo(new[]{ + ViewEvent.DisplaySessions, + ViewEvent.GetSessionId, + ViewEvent.GetSessionTimes + })); + } + + [Test] + public void UpdateSession_NonexistentId_DisplaysErrorAndDoesNotAskForTimes() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 99; + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Coding session 99 was not found.")); + Assert.That(_view.GetSessionTimesCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void UpdateSession_ValidId_GetsTimesAndCallsServiceUpdate() + { + var stored = new CodingSession { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) }; + _service.SessionsToReturn = new List { stored }; + _view.SessionIdToReturn = 1; + _view.SessionTimesToReturn = (DateTime.Now.AddHours(2), DateTime.Now.AddHours(3)); + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_service.UpdatedId, Is.EqualTo(1)); + Assert.That(_service.UpdatedStartTime, Is.EqualTo(_view.SessionTimesToReturn.StartTime)); + Assert.That(_service.UpdatedEndTime, Is.EqualTo(_view.SessionTimesToReturn.EndTime)); + Assert.That(_view.LastMessage, Is.EqualTo("Coding session updated successfully.")); + }); + } + + [Test] + public void UpdateSession_UpdateReturnsFalse_DisplaysError() + { + var stored = new CodingSession { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) }; + _service.SessionsToReturn = new List { stored }; + _view.SessionIdToReturn = 1; + _view.SessionTimesToReturn = (DateTime.Now.AddHours(2), DateTime.Now.AddHours(3)); + _service.UpdateResult = false; + + _controller.UpdateSession(); + + Assert.That(_view.LastError, Is.EqualTo("Coding session 1 was not found.")); + } + + [Test] + public void UpdateSession_InvalidTimes_DisplaysValidationError() + { + var stored = new CodingSession { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) }; + _service.SessionsToReturn = new List { stored }; + _view.SessionIdToReturn = 1; + _view.SessionTimesToReturn = (DateTime.Now.AddHours(2), DateTime.Now.AddHours(1)); + _service.UpdateExceptionToThrow = new ArgumentException("Invalid time range"); + + _controller.UpdateSession(); + + Assert.That(_view.LastError, Is.EqualTo("Invalid time range")); + } + + [Test] + public void DeleteSession_NoSessions_DoesNotAskForId() + { + _service.SessionsToReturn = new List(); + + _controller.DeleteSession(); + + Assert.Multiple(() => + { + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + Assert.That(_view.LastMessage, Is.EqualTo("No coding sessions available to delete.")); + }); + } + + [Test] + public void DeleteSession_SessionsExist_DisplaysSessionsBeforeAskingForId() + { + _service.SessionsToReturn = [ + new CodingSession + { + Id = 2, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + } + ]; + + _view.SessionIdToReturn = 2; + + _controller.DeleteSession(); + + Assert.That(_view.Events, Is.EqualTo(new[] { ViewEvent.DisplaySessions, + ViewEvent.GetSessionId + })); + } + + [Test] + public void DeleteSession_ExistingId_CallsServiceDelete() + { + _service.SessionsToReturn = new List + { + new() { Id = 2, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 2; + + _controller.DeleteSession(); + + Assert.That(_service.DeletedId, Is.EqualTo(2)); + } + + [Test] + public void DeleteSession_DeleteReturnsTrue_DisplaysSuccessMessage() + { + _service.SessionsToReturn = new List + { + new() { Id = 2, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 2; + _service.DeleteResult = true; + + _controller.DeleteSession(); + + Assert.That(_view.LastMessage, Is.EqualTo("Coding session deleted successfully.")); + } + + [Test] + public void DeleteSession_DeleteReturnsFalse_DisplaysError() + { + _service.SessionsToReturn = new List + { + new() { Id = 2, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 2; + _service.DeleteResult = false; + + _controller.DeleteSession(); + + Assert.That(_view.LastError, Is.EqualTo("Coding session 2 was not found.")); + } + + [Test] + public void ViewSessions_DataRetrievalFails_DisplaysErrorAndReturns() + { + _service.GetAllExceptionToThrow = new InvalidOperationException("boom"); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: boom")); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(0)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void UpdateSession_DataRetrievalFails_DisplaysErrorAndReturns() + { + _service.GetAllExceptionToThrow = new InvalidOperationException("boom"); + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: boom")); + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void DeleteSession_DataRetrievalFails_DisplaysErrorAndReturns() + { + _service.GetAllExceptionToThrow = new InvalidOperationException("boom"); + + _controller.DeleteSession(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: boom")); + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void ViewSessions_FilterReturnsEmptyList_StaysInFilteringLoop() + { + _service.SessionsToReturn = [ + new CodingSession + { + Id = 1, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByDay = []; + + _view.FilterDateToReturn = new DateTime(2020, 1, 1); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Day); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1], Is.Empty); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + }); + } + + [Test] + public void ViewSessions_FilterRetrievalFails_DisplaysErrorAndReturns() + { + _service.SessionsToReturn = [ + new CodingSession + { + Id = 1, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + } + ]; + + _view.FilterDateToReturn = new DateTime(2026, 7, 14); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Day); + _service.FilterExceptionToThrow = new InvalidOperationException("filter failed"); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: filter failed")); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(1)); + Assert.That(_service.DayArgument, Is.Null); + }); + } +} diff --git a/CodingTracker.Tests/CodingTracker.Tests.csproj b/CodingTracker.Tests/CodingTracker.Tests.csproj new file mode 100644 index 000000000..8c0dbaefd --- /dev/null +++ b/CodingTracker.Tests/CodingTracker.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/CodingTracker.Tests/CrudAndFilterTests.cs b/CodingTracker.Tests/CrudAndFilterTests.cs new file mode 100644 index 000000000..4279f80cf --- /dev/null +++ b/CodingTracker.Tests/CrudAndFilterTests.cs @@ -0,0 +1,442 @@ +using CodeReviews.Console.CodingTracker; +using Dapper; +namespace CodingTracker.Tests; + +[TestFixture] +[NonParallelizable] +public sealed class CrudAndFilterTests : RepositoryTestBase +{ + private static readonly List sessionTestCases = [ + new TestCaseData( + new DateTime(2026, 7, 14, 9, 0, 0), + new DateTime(2026, 7, 14, 10, 30, 0) + ), + + new TestCaseData( + new DateTime(2026, 7, 14, 23, 30, 0), + new DateTime(2026, 7, 15, 0, 45, 0) + ), + + new TestCaseData( + new DateTime(2026, 1, 31, 22, 0, 0), + new DateTime(2026, 2, 1, 1, 0, 0) + ), + + new TestCaseData( + new DateTime(2025, 12, 31, 23, 0, 0), + new DateTime(2026, 1, 1, 2, 0, 0) + ), + + new TestCaseData( + new DateTime(2026, 7, 14, 12, 0, 15), + new DateTime(2026, 7, 14, 12, 0, 45) + ), + ]; + + [Test] + public void GetAll_NewDatabase_ReturnsEmptyList() + { + List sessions = repository.GetAll(); + Assert.That(sessions, Is.Empty); + } + + [TestCaseSource(nameof(sessionTestCases))] + public void Add_ValidSession_SavesSession(DateTime start, DateTime end) + { + var session = new CodingSession + { + StartTime = start, + EndTime = end + }; + + repository.Add(session); + + List result = repository.GetAll(); + + Assert.That(result, Has.Count.EqualTo(1)); + + CodingSession saved = result.Single(); + + Assert.Multiple(() => + { + Assert.That(saved.Id, Is.GreaterThan(0)); + Assert.That(saved.StartTime, Is.EqualTo(session.StartTime)); + Assert.That(saved.EndTime, Is.EqualTo(session.EndTime)); + Assert.That(saved.Duration, Is.EqualTo(session.EndTime - session.StartTime)); + }); + } + + [Test] + public void Add_NullSession_ThrowsArgumentNullException() + { + Assert.That(() => repository.Add(null!), Throws.TypeOf()); + } + + [TestCaseSource(nameof(sessionTestCases))] + public void GetOne_ExistingId_ReturnsSession(DateTime start, DateTime end) + { + var session = new CodingSession + { + StartTime = start, + EndTime = end + }; + + repository.Add(session); + + long savedId = repository.GetAll().Single().Id; + + CodingSession? result = repository.GetOne(savedId); + + Assert.That(result, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(result!.Id, Is.EqualTo(savedId)); + Assert.That(result.StartTime, Is.EqualTo(session.StartTime)); + Assert.That(result.EndTime, Is.EqualTo(session.EndTime)); + }); + } + + [Test] + public void GetOne_NonexistentId_ReturnsNull() + { + CodingSession? result = repository.GetOne(999999L); + Assert.That(result, Is.Null); + } + + [Test] + public void GetAll_MultipleSessions_ReturnsNewestFirst() + { + var oldest = new CodingSession + { + StartTime = new DateTime(2026, 7, 12, 10, 0, 0), + EndTime = new DateTime(2026, 7, 12, 11, 0, 0) + }; + + var newest = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 10, 0, 0), + EndTime = new DateTime(2026, 7, 14, 11, 0, 0) + }; + + var middle = new CodingSession + { + StartTime = new DateTime(2026, 7, 13, 10, 0, 0), + EndTime = new DateTime(2026, 7, 13, 11, 0, 0) + }; + + repository.Add(oldest); + repository.Add(newest); + repository.Add(middle); + + List result = repository.GetAll(); + + DateTime[] actualOrder = result.Select(x => x.StartTime).ToArray(); + + DateTime[] expectedOrder = + [ + newest.StartTime, + middle.StartTime, + oldest.StartTime + ]; + + Assert.That(actualOrder, Is.EqualTo(expectedOrder)); + } + + [Test] + public void Update_ExistingSession_ChangesStoredTimes() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + CodingSession stored = repository.GetAll().Single(); + + stored.StartTime = new DateTime(2026, 7, 14, 12, 30, 0); + stored.EndTime = new DateTime(2026, 7, 14, 14, 15, 0); + + bool updated = repository.Update(stored); + + CodingSession? reloaded = repository.GetOne(stored.Id); + + Assert.That(updated, Is.True); + Assert.That(reloaded, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(reloaded!.StartTime, Is.EqualTo(stored.StartTime)); + Assert.That(reloaded.EndTime, Is.EqualTo(stored.EndTime)); + }); + } + + [Test] + public void Update_NonexistentSession_ReturnsFalse() + { + var session = new CodingSession + { + Id = 99999L, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + bool result = repository.Update(session); + + Assert.That(result, Is.False); + } + + [Test] + public void Delete_ExistingSession_RemovesSession() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + long id = repository.GetAll().Single().Id; + + bool deleted = repository.Delete(id); + + Assert.Multiple(() => + { + Assert.That(deleted, Is.True); + Assert.That(repository.GetOne(id), Is.Null); + Assert.That(repository.GetAll(), Is.Empty); + }); + } + + [Test] + public void Delete_NonexistentId_ReturnsFalse() + { + bool result = repository.Delete(999_999); + + Assert.That(result, Is.False); + } + + [Test] + public void Delete_ExistingSession_RemovesOnlySelectedSession() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 15, 8, 0, 0), + EndTime = new DateTime(2026, 7, 15, 9, 0, 0) + }); + + List stored = repository.GetAll(); + + long deletedId = stored[0].Id; + long remainingId = stored[1].Id; + + bool result = repository.Delete(deletedId); + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(repository.GetOne(deletedId), Is.Null); + Assert.That(repository.GetOne(remainingId), Is.Not.Null); + Assert.That(repository.GetAll(), Has.Count.EqualTo(1)); + }); + } + + [Test] + public void Update_ExistingSession_ChangesOnlySelectedSession() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 15, 8, 0, 0), + EndTime = new DateTime(2026, 7, 15, 9, 0, 0) + }); + + List stored = repository.GetAll(); + + CodingSession target = stored.Single(session => session.StartTime.Day == 14); + + CodingSession untouched = stored.Single(session => session.StartTime.Day == 15); + + DateTime originalUntouchedStart = untouched.StartTime; + DateTime originalUntouchedEnd = untouched.EndTime; + + target.StartTime = new DateTime(2026, 7, 14, 12, 0, 0); + target.EndTime = new DateTime(2026, 7, 14, 14, 0, 0); + + bool result = repository.Update(target); + + CodingSession? updated = repository.GetOne(target.Id); + + CodingSession? unchanged = repository.GetOne(untouched.Id); + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(updated!.StartTime, Is.EqualTo(target.StartTime)); + Assert.That(updated.EndTime, Is.EqualTo(target.EndTime)); + Assert.That(unchanged!.StartTime, Is.EqualTo(originalUntouchedStart)); + Assert.That(unchanged.EndTime, Is.EqualTo(originalUntouchedEnd)); + }); + } + + [Test] + public void GetSessionsInBetweenDates_IncludesSessionAtStartBoundary() + { + DateTime start = new(2026, 7, 14, 9, 0, 0); + DateTime end = new(2026, 7, 14, 12, 0, 0); + + repository.Add(new CodingSession + { + StartTime = start, + EndTime = start.AddHours(1) + }); + + List result = + repository.GetSessionsInBetweenDates(start, end); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Single().StartTime, Is.EqualTo(start)); + } + + [Test] + public void Update_NullSession_ThrowsArgumentNullException() + { + Assert.That(() => repository.Update(null!), Throws.TypeOf()); + } + + [Test] + public void GetSessionsByDay_ReturnsOnlySessionsStartingOnGivenDay() + { + var atStartOfDay = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 0, 0, 0), + EndTime = new DateTime(2026, 7, 14, 1, 0, 0) + }; + + var duringDay = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 15, 30, 0), + EndTime = new DateTime(2026, 7, 14, 16, 30, 0) + }; + + var nextDay = new CodingSession + { + StartTime = new DateTime(2026, 7, 15, 0, 0, 0), + EndTime = new DateTime(2026, 7, 15, 1, 0, 0) + }; + + repository.Add(atStartOfDay); + repository.Add(duringDay); + repository.Add(nextDay); + + List result = repository.GetSessionsByDay(new DateTime(2026, 7, 14)); + + Assert.That(result, Has.Count.EqualTo(2)); + + Assert.That(result.Select(x => x.StartTime), Is.EquivalentTo(new[] { atStartOfDay.StartTime, duringDay.StartTime })); + } + [Test] + public void GetSessionsByMonthOfYear_ReturnsOnlySessionsStartingInGivenMonthAndYear() + { + var januaryFirst = new CodingSession + { + StartTime = new DateTime(2026, 1, 5, 10, 0, 0), + EndTime = new DateTime(2026, 1, 5, 11, 0, 0) + }; + + var januaryLast = new CodingSession + { + StartTime = new DateTime(2026, 1, 31, 22, 0, 0), + EndTime = new DateTime(2026, 2, 1, 1, 0, 0) + }; + + var february = new CodingSession + { + StartTime = new DateTime(2026, 2, 1, 8, 0, 0), + EndTime = new DateTime(2026, 2, 1, 9, 0, 0) + }; + + repository.Add(januaryFirst); + repository.Add(januaryLast); + repository.Add(february); + + List result = repository.GetSessionsByMonthOfYear(2026, 1); + + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result.Select(x => x.StartTime), Is.EqualTo(new[] { januaryLast.StartTime, januaryFirst.StartTime })); + } + + [Test] + public void GetSessionsInBetweenDates_ReturnsOnlySessionsStartingWithinGivenRange() + { + var beforeRange = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + var insideRange = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 10, 0, 0), + EndTime = new DateTime(2026, 7, 14, 11, 0, 0) + }; + + var atEndBoundary = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 12, 0, 0), + EndTime = new DateTime(2026, 7, 14, 13, 0, 0) + }; + + repository.Add(beforeRange); + repository.Add(insideRange); + repository.Add(atEndBoundary); + + List result = repository.GetSessionsInBetweenDates( + new DateTime(2026, 7, 14, 9, 0, 0), + new DateTime(2026, 7, 14, 12, 0, 0)); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Single().StartTime, Is.EqualTo(insideRange.StartTime)); + } + + [Test] + public void GetSessionsByYear_ReturnsOnlySessionsStartingInGivenYear() + { + var previousYear = new CodingSession + { + StartTime = new DateTime(2025, 12, 31, 23, 0, 0), + EndTime = new DateTime(2026, 1, 1, 1, 0, 0) + }; + + var targetYear = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + var nextYear = new CodingSession + { + StartTime = new DateTime(2027, 1, 1, 8, 0, 0), + EndTime = new DateTime(2027, 1, 1, 9, 0, 0) + }; + + repository.Add(previousYear); + repository.Add(targetYear); + repository.Add(nextYear); + + List result = repository.GetSessionsByYear(2026); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Single().StartTime, Is.EqualTo(targetYear.StartTime)); + } +} diff --git a/CodingTracker.Tests/RepositoryTestBase.cs b/CodingTracker.Tests/RepositoryTestBase.cs new file mode 100644 index 000000000..3abf2e14b --- /dev/null +++ b/CodingTracker.Tests/RepositoryTestBase.cs @@ -0,0 +1,36 @@ +using CodeReviews.Console.CodingTracker; +using Microsoft.Data.Sqlite; +using Dapper; +namespace CodingTracker.Tests; + +[TestFixture] +[NonParallelizable] +public abstract class RepositoryTestBase +{ + protected string dbPath; + protected ICodingSessionRepo repository; + + [SetUp] + public void SetUp() + { + dbPath = Path.Combine(Path.GetTempPath(), $"coding-tracker-tests-{Guid.NewGuid():N}.db"); + + string connectionString = $"Data Source={dbPath};Pooling=False"; + + IDatabaseConnectionFactory connectionFactory = new SQLiteConnectionFactory(connectionString); + var initializer = new DatabaseInitializer(connectionFactory); + + initializer.Initialize(); + + repository = new CodingSessionRepo(connectionFactory); + } + + [TearDown] + public void DeleteDatabase() + { + if (File.Exists(dbPath)) + { + File.Delete(dbPath); + } + } +} diff --git a/CodingTracker.Tests/ServiceTests.cs b/CodingTracker.Tests/ServiceTests.cs new file mode 100644 index 000000000..a7991102a --- /dev/null +++ b/CodingTracker.Tests/ServiceTests.cs @@ -0,0 +1,413 @@ +using System.Linq; +using CodeReviews.Console.CodingTracker; + +namespace CodingTracker.Tests; + +[TestFixture] +[NonParallelizable] +public sealed class ServiceTests +{ + private CodingSessionService _service; + private CodingSessionRepoDouble _repository; + + private static readonly List WeekTestCases = + [ + new TestCaseData( + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 20, 0, 0, 0)), + + new TestCaseData( + new DateTime(2026, 7, 15, 18, 30, 0), + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 20, 0, 0, 0)), + + new TestCaseData( + new DateTime(2026, 7, 19, 12, 0, 0), + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 20, 0, 0, 0)), + + new TestCaseData( + new DateTime(2025, 12, 31, 22, 0, 0), + new DateTime(2025, 12, 29, 0, 0, 0), + new DateTime(2026, 1, 5, 0, 0, 0)), + ]; + + private static readonly List InvalidIdTestCases = [ + new TestCaseData(0L), + new TestCaseData(-1L), + new TestCaseData(-999L), + new TestCaseData(long.MinValue), + ]; + + [SetUp] + public void SetUp() + { + _repository = new CodingSessionRepoDouble(); + _service = new CodingSessionService(_repository); + } + + // ADD + + [Test] + public void Add_ValidTimes_CallsRepositoryWithCreatedSession() + { + DateTime startTime = new DateTime(2026, 7, 14, 8, 0, 0); + DateTime endTime = new DateTime(2026, 7, 14, 10, 0, 0); + + _service.Add(startTime, endTime); + + Assert.That(_repository.AddedSessions, Has.Count.EqualTo(1)); + + CodingSession added = _repository.AddedSessions.Single(); + + Assert.Multiple(() => + { + Assert.That(added.StartTime, Is.EqualTo(startTime)); + Assert.That(added.EndTime, Is.EqualTo(endTime)); + Assert.That(added.Duration, Is.EqualTo(endTime - startTime)); + }); + } + + [TestCase(-1)] + [TestCase(0)] + public void Add_EndNotAfterStart_ThrowsArgumentException(int endOffsetMinutes) + { + DateTime startTime = new DateTime(2026, 7, 14, 8, 0, 0); + DateTime endTime = startTime.AddMinutes(endOffsetMinutes); + + Assert.That( + () => _service.Add(startTime, endTime), + Throws.TypeOf()); + + Assert.That(_repository.AddedSessions, Is.Empty); + } + + // DELETE + + [TestCaseSource(nameof(InvalidIdTestCases))] + public void Delete_InvalidId_ReturnsFalseWithoutCallingRepository(long id) + { + bool result = _service.Delete(id); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(_repository.DeletedId, Is.Null); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void Delete_ValidId_ReturnsRepositoryResult(bool repositoryResult) + { + _repository.DeleteResult = repositoryResult; + + bool result = _service.Delete(3); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(repositoryResult)); + Assert.That(_repository.DeletedId, Is.EqualTo(3)); + }); + } + + // GET ALL + + [Test] + public void GetAll_ReturnsSessionsFromRepository() + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(2026, 7, 14, 8, 0, 0), EndTime = new DateTime(2026, 7, 14, 9, 0, 0) }, + new() { Id = 2, StartTime = new DateTime(2026, 7, 14, 10, 0, 0), EndTime = new DateTime(2026, 7, 14, 11, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetAll(); + + Assert.That(result, Is.SameAs(stored)); + } + + // GET ONE + + [TestCaseSource(nameof(InvalidIdTestCases))] + public void GetOne_InvalidId_ReturnsNullWithoutCallingRepository(long id) + { + CodingSession? result = _service.GetOne(id); + + Assert.Multiple(() => + { + Assert.That(result, Is.Null); + Assert.That(_repository.GetOneId, Is.Null); + }); + } + + [Test] + public void GetOne_ValidIdWhenSessionExists_ReturnsSessionFromRepository() + { + var expected = new CodingSession + { + Id = 5, + StartTime = new DateTime(2026, 7, 14, 9, 0, 0), + EndTime = new DateTime(2026, 7, 14, 10, 0, 0) + }; + + _repository.SessionsToReturn = new List { expected }; + + CodingSession? result = _service.GetOne(expected.Id); + + Assert.Multiple(() => + { + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.SameAs(expected)); + Assert.That(_repository.GetOneId, Is.EqualTo(expected.Id)); + }); + } + + [Test] + public void GetOne_ValidIdWhenSessionDoesNotExist_ReturnsNull() + { + _repository.SessionsToReturn = new List(); + + CodingSession? result = _service.GetOne(10); + + Assert.Multiple(() => + { + Assert.That(result, Is.Null); + Assert.That(_repository.GetOneId, Is.EqualTo(10)); + }); + } + + // UPDATE + + [TestCaseSource(nameof(InvalidIdTestCases))] + public void Update_InvalidId_ReturnsFalseWithoutCallingRepository(long id) + { + bool result = _service.Update( + id, + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 9, 0, 0)); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(_repository.GetOneId, Is.Null); + Assert.That(_repository.UpdatedId, Is.Null); + }); + } + + [TestCase(-1)] + [TestCase(0)] + public void Update_EndNotAfterStart_ThrowsArgumentException( + int endOffsetMinutes) + { + DateTime startTime = new DateTime(2026, 7, 14, 8, 0, 0); + DateTime endTime = startTime.AddMinutes(endOffsetMinutes); + + Assert.That( + () => _service.Update(1, startTime, endTime), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(_repository.GetOneId, Is.Null); + Assert.That(_repository.UpdatedId, Is.Null); + Assert.That(_repository.UpdatedSession, Is.Null); + }); + } + + [Test] + public void Update_NonexistentSession_ReturnsFalseWithoutCallingUpdate() + { + _repository.SessionsToReturn = new List(); + + bool result = _service.Update( + 11, + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 9, 0, 0)); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(_repository.GetOneId, Is.EqualTo(11)); + Assert.That(_repository.UpdatedId, Is.Null); + }); + } + + [Test] + public void Update_ExistingSession_ChangesTimesAndCallsRepositoryUpdate() + { + var existing = new CodingSession + { + Id = 7, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + _repository.SessionsToReturn = new List { existing }; + + bool result = _service.Update( + existing.Id, + new DateTime(2026, 7, 14, 10, 0, 0), + new DateTime(2026, 7, 14, 11, 30, 0)); + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(_repository.UpdatedId, Is.EqualTo(existing.Id)); + Assert.That(_repository.UpdatedSession, Is.Not.Null); + Assert.That(_repository.UpdatedSession!.StartTime, Is.EqualTo(new DateTime(2026, 7, 14, 10, 0, 0))); + Assert.That(_repository.UpdatedSession.EndTime, Is.EqualTo(new DateTime(2026, 7, 14, 11, 30, 0))); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void Update_ExistingSession_ReturnsRepositoryResult( + bool repositoryResult) + { + var existing = new CodingSession + { + Id = 8, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + _repository.SessionsToReturn = new List { existing }; + _repository.UpdateResult = repositoryResult; + + bool result = _service.Update( + existing.Id, + new DateTime(2026, 7, 14, 10, 0, 0), + new DateTime(2026, 7, 14, 11, 0, 0)); + + Assert.That(result, Is.EqualTo(repositoryResult)); + } + + // FILTER BY DAY + + [Test] + public void GetByDay_ValidDate_ReturnsSessionsFromRepository() + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(2026, 7, 14, 8, 0, 0), EndTime = new DateTime(2026, 7, 14, 9, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByDay(new DateTime(2026, 7, 14)); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.DayForGetByDay, Is.EqualTo(new DateTime(2026, 7, 14))); + }); + } + + // FILTER BY WEEK + + [TestCaseSource(nameof(WeekTestCases))] + public void GetByWeek_ValidDate_UsesCorrectMondayToMondayRange( + DateTime selectedDate, + DateTime expectedStart, + DateTime expectedEnd) + { + var stored = new List + { + new() { Id = 1, StartTime = selectedDate, EndTime = selectedDate.AddHours(1) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByWeek(selectedDate); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.BetweenDatesArgs?.Start, Is.EqualTo(expectedStart)); + Assert.That(_repository.BetweenDatesArgs?.End, Is.EqualTo(expectedEnd)); + }); + } + + // FILTER BY MONTH AND YEAR + + [TestCase(0, 1)] + [TestCase(-1, 1)] + [TestCase(10000, 1)] + [TestCase(2026, 0)] + [TestCase(2026, -1)] + [TestCase(2026, 13)] + public void GetByMonthOfYear_InvalidArguments_ThrowsArgumentOutOfRangeException( + int year, + int month) + { + Assert.That( + () => _service.GetByMonthOfYear(year, month), + Throws.TypeOf()); + + Assert.That(_repository.MonthOfYearArgs, Is.Null); + } + + [TestCase(2026, 1)] + [TestCase(2026, 7)] + [TestCase(2025, 12)] + public void GetByMonthOfYear_ValidArguments_ReturnsSessionsFromRepository( + int year, + int month) + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(year, month, 1, 8, 0, 0), EndTime = new DateTime(year, month, 1, 9, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByMonthOfYear(year, month); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.MonthOfYearArgs, Is.EqualTo((year, month))); + }); + } + + // FILTER BY YEAR + + [TestCase(0)] + [TestCase(-1)] + [TestCase(10000)] + public void GetByYear_InvalidYear_ThrowsArgumentOutOfRangeException( + int year) + { + Assert.That( + () => _service.GetByYear(year), + Throws.TypeOf()); + + Assert.That(_repository.YearArg, Is.Null); + } + + [TestCase(1)] + [TestCase(2026)] + [TestCase(9999)] + public void GetByYear_ValidYear_ReturnsSessionsFromRepository( + int year) + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(year, 1, 1, 8, 0, 0), EndTime = new DateTime(year, 1, 1, 9, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByYear(year); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.YearArg, Is.EqualTo(year)); + }); + } +} diff --git a/CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs b/CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs new file mode 100644 index 000000000..e6daaee18 --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs @@ -0,0 +1,68 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class CodingSessionRepoDouble : ICodingSessionRepo +{ + public List SessionsToReturn { get; set; } = new(); + public List AddedSessions { get; } = new(); + public long? DeletedId { get; private set; } + public bool DeleteResult { get; set; } = true; + public long? GetOneId { get; private set; } + public long? UpdatedId { get; private set; } + public CodingSession? UpdatedSession { get; private set; } + public bool UpdateResult { get; set; } = true; + public DateTime? DayForGetByDay { get; private set; } + public (DateTime Start, DateTime End)? BetweenDatesArgs { get; private set; } + public (int Year, int Month)? MonthOfYearArgs { get; private set; } + public int? YearArg { get; private set; } + + public List GetAll() => SessionsToReturn; + + public CodingSession? GetOne(long id) + { + GetOneId = id; + return SessionsToReturn.SingleOrDefault(x => x.Id == id); + } + + public void Add(CodingSession session) + { + AddedSessions.Add(session); + SessionsToReturn.Add(session); + } + + public bool Update(CodingSession session) + { + UpdatedId = session.Id; + UpdatedSession = session; + return UpdateResult; + } + + public bool Delete(long id) + { + DeletedId = id; + return DeleteResult; + } + + public List GetSessionsByDay(DateTime day) + { + DayForGetByDay = day; + return SessionsToReturn; + } + + public List GetSessionsInBetweenDates(DateTime start, DateTime end) + { + BetweenDatesArgs = (start, end); + return SessionsToReturn; + } + + public List GetSessionsByMonthOfYear(int year, int monthIndicator) + { + MonthOfYearArgs = (year, monthIndicator); + return SessionsToReturn; + } + + public List GetSessionsByYear(int year) + { + YearArg = year; + return SessionsToReturn; + } +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeAppView.cs b/CodingTracker.Tests/TestDoubles/FakeAppView.cs new file mode 100644 index 000000000..9bf5ab7cb --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeAppView.cs @@ -0,0 +1,14 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class FakeAppView : IAppView +{ + public Queue OptionsToReturn { get; } = new(); + public int DisplayGoodbyeCalls { get; private set; } + + public MenuOption DisplayMainMenu() => OptionsToReturn.Count > 0 + ? OptionsToReturn.Dequeue() + : MenuOption.Close; + + public void DisplayMessage(string message) { } + public void DisplayGoodbye() => DisplayGoodbyeCalls++; +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs b/CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs new file mode 100644 index 000000000..33a86fdaa --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs @@ -0,0 +1,13 @@ +using CodeReviews.Console.CodingTracker; +internal sealed class FakeCodingSessionController : ICodingSessionController +{ + public int ViewSessionsCalls { get; private set; } + public int AddSessionCalls { get; private set; } + public int UpdateSessionCalls { get; private set; } + public int DeleteSessionCalls { get; private set; } + + public void ViewSessions() => ViewSessionsCalls++; + public void AddSession() => AddSessionCalls++; + public void UpdateSession() => UpdateSessionCalls++; + public void DeleteSession() => DeleteSessionCalls++; +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs b/CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs new file mode 100644 index 000000000..68a2e30ad --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs @@ -0,0 +1,127 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class FakeCodingSessionService : ICodingSessionService +{ + public List SessionsToReturn { get; set; } = new(); + public List? SessionsToReturnForGetOne { get; set; } + public List? SessionsToReturnForGetByDay { get; set; } + public List? SessionsToReturnForGetByWeek { get; set; } + public List? SessionsToReturnForGetByMonth { get; set; } + public List? SessionsToReturnForGetByYear { get; set; } + + public bool AddCalled { get; private set; } + public DateTime? AddedStartTime { get; private set; } + public DateTime? AddedEndTime { get; private set; } + public Exception? AddExceptionToThrow { get; set; } + public Exception? FilterExceptionToThrow { get; set; } + + public int GetAllCalls { get; private set; } + public Exception? GetAllExceptionToThrow { get; set; } + + public long? DeletedId { get; private set; } + public bool DeleteResult { get; set; } = true; + public Exception? DeleteExceptionToThrow { get; set; } + + public long? UpdatedId { get; private set; } + public DateTime? UpdatedStartTime { get; private set; } + public DateTime? UpdatedEndTime { get; private set; } + public bool UpdateResult { get; set; } = true; + public Exception? UpdateExceptionToThrow { get; set; } + + public DateTime? DayArgument { get; private set; } + public DateTime? WeekArgument { get; private set; } + public (int Year, int Month)? MonthArguments { get; private set; } + public int? YearArgument { get; private set; } + + public void Add(DateTime startTime, DateTime endTime) + { + AddCalled = true; + AddedStartTime = startTime; + AddedEndTime = endTime; + if (AddExceptionToThrow != null) + { + throw AddExceptionToThrow; + } + } + + public bool Delete(long id) + { + if (DeleteExceptionToThrow != null) + { + throw DeleteExceptionToThrow; + } + DeletedId = id; + return DeleteResult; + } + + public List GetAll() + { + if (GetAllExceptionToThrow != null) + { + throw GetAllExceptionToThrow; + } + GetAllCalls++; + return SessionsToReturn; + } + + public CodingSession? GetOne(long id) + { + if (SessionsToReturnForGetOne != null) + { + return SessionsToReturnForGetOne.SingleOrDefault(x => x.Id == id); + } + return SessionsToReturn.SingleOrDefault(x => x.Id == id); + } + + public bool Update(long id, DateTime startTime, DateTime endTime) + { + if (UpdateExceptionToThrow != null) + { + throw UpdateExceptionToThrow; + } + UpdatedId = id; + UpdatedStartTime = startTime; + UpdatedEndTime = endTime; + return UpdateResult; + } + + public List GetByDay(DateTime date) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + DayArgument = date; + + return SessionsToReturnForGetByDay ?? SessionsToReturn; + } + + public List GetByWeek(DateTime date) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + WeekArgument = date; + + return SessionsToReturnForGetByWeek ?? SessionsToReturn; + } + + public List GetByMonthOfYear(int year, int month) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + MonthArguments = (year, month); + + return SessionsToReturnForGetByMonth ?? SessionsToReturn; + } + + public List GetByYear(int year) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + YearArgument = year; + + return SessionsToReturnForGetByYear ?? SessionsToReturn; + } +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs b/CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs new file mode 100644 index 000000000..812532818 --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs @@ -0,0 +1,75 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class FakeCodingSessionView : ICodingSessionView +{ + public List Events { get; } = []; + public AddSessionOption AddSessionOptionToReturn { get; set; } + public Queue FilterOptionsToReturn { get; } = new(); + public DateTime FilterDateToReturn { get; set; } + public Month MonthToReturn { get; set; } + public int YearToReturn { get; set; } + public long SessionIdToReturn { get; set; } + public (DateTime StartTime, DateTime EndTime) SessionTimesToReturn { get; set; } + + public int DisplaySessionsCalls { get; private set; } + public List> DisplayedSessions { get; } = new(); + public int DisplayFilterMenuCalls { get; private set; } + public int GetSessionIdCalls { get; private set; } + public int GetSessionTimesCalls { get; private set; } + public int DisplayMessageCount { get; private set; } + public int DisplayErrorCount { get; private set; } + public bool WaitStopwatchCalled { get; private set; } + public string? LastMessage { get; private set; } + public string? LastError { get; private set; } + + public void DisplaySessions(List sessions) + { + Events.Add(ViewEvent.DisplaySessions); + DisplaySessionsCalls++; + DisplayedSessions.Add(new List(sessions)); + } + + public (DateTime StartTime, DateTime EndTime) GetSessionTimes() + { + Events.Add(ViewEvent.GetSessionTimes); + GetSessionTimesCalls++; + return SessionTimesToReturn; + } + + public long GetSessionId(string prompt) + { + Events.Add(ViewEvent.GetSessionId); + GetSessionIdCalls++; + return SessionIdToReturn; + } + + public void DisplayMessage(string message) + { + DisplayMessageCount++; + LastMessage = message; + } + + public void DisplayError(string message) + { + DisplayErrorCount++; + LastError = message; + } + + public Month GetMonth() => MonthToReturn; + public int GetYear() => YearToReturn; + + public FilterOption DisplayFilterMenu() + { + DisplayFilterMenuCalls++; + if (FilterOptionsToReturn.Count == 0) + throw new InvalidOperationException("No filter option was configured for this test."); + + return FilterOptionsToReturn.Dequeue(); + } + + public DateTime GetFilterDate(string prompt) => FilterDateToReturn; + + public AddSessionOption DisplayAddSessionMenu() => AddSessionOptionToReturn; + + public void WaitStopwatch() => WaitStopwatchCalled = true; +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/ViewEvent.cs b/CodingTracker.Tests/TestDoubles/ViewEvent.cs new file mode 100644 index 000000000..8910e465a --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/ViewEvent.cs @@ -0,0 +1,6 @@ +internal enum ViewEvent +{ + DisplaySessions, + GetSessionId, + GetSessionTimes +} \ No newline at end of file diff --git a/CodingTracker/.vscode/launch.json b/CodingTracker/.vscode/launch.json new file mode 100644 index 000000000..808ad2a05 --- /dev/null +++ b/CodingTracker/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/CodeReviews.Console.CodingTracker/CodingTracker/bin/Debug/net9.0/CodingTracker.dll", + "args": [], + "cwd": "${workspaceFolder}/TCSA.OOP.LibraryManagementSystem", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "externalTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + + ] +} \ No newline at end of file diff --git a/CodingTracker/.vscode/tasks.json b/CodingTracker/.vscode/tasks.json new file mode 100644 index 000000000..8b680739f --- /dev/null +++ b/CodingTracker/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "/CodeReviews.Console.CodingTracker/CodingTracker/CodingTracker.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "/CodeReviews.Console.CodingTracker/CodingTracker/CodingTracker.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "/CodeReviews.Console.CodingTracker/CodingTracker/CodingTracker.sln", + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj new file mode 100644 index 000000000..dc679a002 --- /dev/null +++ b/CodingTracker/CodingTracker.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + PreserveNewest + + + + diff --git a/CodingTracker/CodingTracker.sln b/CodingTracker/CodingTracker.sln new file mode 100644 index 000000000..2831a8b22 --- /dev/null +++ b/CodingTracker/CodingTracker.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36429.23 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker", "CodingTracker.csproj", "{594963C2-24C3-4E63-8C77-61866EE59BA0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Tests", "..\CodingTracker.Tests\CodingTracker.Tests.csproj", "{1E256DC0-2072-4D3D-9461-5D49C9FA7211}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Release|Any CPU.Build.0 = Release|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CB79C781-C2D7-4707-8D9E-F58220752302} + EndGlobalSection +EndGlobal diff --git a/CodingTracker/Configuration/appsettings.json b/CodingTracker/Configuration/appsettings.json new file mode 100644 index 000000000..8ff46b7c4 --- /dev/null +++ b/CodingTracker/Configuration/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=coding-tracker.db" + } +} \ No newline at end of file diff --git a/CodingTracker/Controller/AppController.cs b/CodingTracker/Controller/AppController.cs new file mode 100644 index 000000000..6859a596d --- /dev/null +++ b/CodingTracker/Controller/AppController.cs @@ -0,0 +1,53 @@ +using Spectre.Console; +namespace CodeReviews.Console.CodingTracker; + +public sealed class AppController +{ + private readonly IAppView _appView; + private readonly ICodingSessionController _codingController; + public AppController(IAppView _view, ICodingSessionController _controller) + { + _appView = _view; + _codingController = _controller; + } + + public void Run() + { + bool isRunning = true; + + while (isRunning) + { + MenuOption selectedOption = _appView.DisplayMainMenu(); + + switch (selectedOption) + { + case MenuOption.View: + _codingController.ViewSessions(); + break; + + case MenuOption.Add: + _codingController.AddSession(); + break; + + case MenuOption.Edit: + _codingController.UpdateSession(); + break; + + case MenuOption.Delete: + _codingController.DeleteSession(); + break; + + case MenuOption.Close: + isRunning = false; + break; + + default: + throw new ArgumentOutOfRangeException( + nameof(selectedOption), + selectedOption, + "Unknown menu option."); + } + } + _appView.DisplayGoodbye(); + } +} \ No newline at end of file diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs new file mode 100644 index 000000000..1d3337fbe --- /dev/null +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -0,0 +1,208 @@ +using Microsoft.Data.Sqlite; + +namespace CodeReviews.Console.CodingTracker; + +public sealed class CodingSessionController : ICodingSessionController +{ + private readonly ICodingSessionService _codingService; + private readonly ICodingSessionView _codingView; + + public CodingSessionController(ICodingSessionService service, ICodingSessionView view) + { + _codingService = service; + _codingView = view; + } + public void AddSession() + { + AddSessionOption opt = _codingView.DisplayAddSessionMenu(); + + switch (opt) + { + case AddSessionOption.Manual: + AddManualSession(); + break; + + case AddSessionOption.Stopwatch: + AddTimedSession(); + break; + + case AddSessionOption.Back: + return; + + default: + throw new ArgumentOutOfRangeException( + nameof(opt), + opt, + "Unknown add-session option."); + } + } + + public void DeleteSession() + { + List? sessions = GetSessionsSafely(_codingService.GetAll); + if (sessions == null || sessions.Count == 0) + { + _codingView.DisplayMessage("No coding sessions available to delete."); + return; + } + + _codingView.DisplaySessions(sessions); + + long id = _codingView.GetSessionId("Enter the ID of the session to delete:"); + try + { + bool deleted = _codingService.Delete(id); + if (!deleted) + { + _codingView.DisplayError($"Coding session {id} was not found."); + return; + } + _codingView.DisplayMessage("Coding session deleted successfully."); + } + catch (SqliteException ex) + { + _codingView.DisplayError($"Unexpected SQLite error {ex.SqliteErrorCode}: {ex.Message}"); + } + } + + public void UpdateSession() + { + List? sessions = GetSessionsSafely(_codingService.GetAll); + if (sessions == null || sessions.Count == 0) + { + _codingView.DisplayMessage("No coding sessions available to update."); + return; + } + + _codingView.DisplaySessions(sessions); + + long id = _codingView.GetSessionId("Enter the ID of the session to update:"); + + CodingSession? session = _codingService.GetOne(id); + if (session == null) + { + _codingView.DisplayError($"Coding session {id} was not found."); + return; + } + + var (startTime, endTime) = _codingView.GetSessionTimes(); + + try + { + bool updated = _codingService.Update(id, startTime, endTime); + if (!updated) + { + _codingView.DisplayError($"Coding session {id} was not found."); + return; + } + _codingView.DisplayMessage("Coding session updated successfully."); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } + + public void ViewSessions() + { + List? sessions = GetSessionsSafely(_codingService.GetAll); + if (sessions is null) return; + + _codingView.DisplaySessions(sessions); + if (sessions.Count == 0) return; + + while (true) + { + FilterOption opt = _codingView.DisplayFilterMenu(); + if (opt == FilterOption.Back) return; + + List? filtered = GetFilteredSessions(opt); + + if (filtered == null) return; + + sessions = filtered; + _codingView.DisplaySessions(sessions); + } + + } + + private List? GetSessionsSafely(Func> retrieval) + { + try + { + return retrieval(); + } + catch (Exception ex) + { + _codingView.DisplayError($"Unexpected error: {ex.Message}"); + return null; + } + } + private List? GetFilteredSessions(FilterOption option) + { + switch (option) + { + case FilterOption.ShowAll: + return GetSessionsSafely(_codingService.GetAll); + case FilterOption.Day: + { + DateTime date = _codingView.GetFilterDate("Enter the date:"); + return GetSessionsSafely(() => _codingService.GetByDay(date)); + } + case FilterOption.Week: + { + DateTime date = _codingView.GetFilterDate("Enter any date within the desired week:"); + return GetSessionsSafely(() => _codingService.GetByWeek(date)); + } + case FilterOption.Month: + { + Month month = _codingView.GetMonth(); + int year = _codingView.GetYear(); + return GetSessionsSafely(() => _codingService.GetByMonthOfYear(year, (int)month)); + } + + case FilterOption.Year: + { + int year = _codingView.GetYear(); + return GetSessionsSafely(() => _codingService.GetByYear(year)); + } + + default: + throw new ArgumentOutOfRangeException(nameof(option), option, "Unknown filter option."); + } + } + + private void AddManualSession() + { + var (startTime, endTime) = _codingView.GetSessionTimes(); + + try + { + _codingService.Add(startTime, endTime); + _codingView.DisplayMessage("Coding session added successfully."); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } + + private void AddTimedSession() + { + DateTime startTime = DateTime.Now; + + _codingView.WaitStopwatch(); + + DateTime endTime = DateTime.Now; + + try + { + _codingService.Add(startTime, endTime); + _codingView.DisplayMessage($"Session recorded. Duration: " + $"{endTime - startTime:hh\\:mm\\:ss}"); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } +} \ No newline at end of file diff --git a/CodingTracker/Controller/ICodingSessionController.cs b/CodingTracker/Controller/ICodingSessionController.cs new file mode 100644 index 000000000..d7110c5c2 --- /dev/null +++ b/CodingTracker/Controller/ICodingSessionController.cs @@ -0,0 +1,10 @@ +public interface ICodingSessionController +{ + void ViewSessions(); + + void AddSession(); + + void UpdateSession(); + + void DeleteSession(); +} \ No newline at end of file diff --git a/CodingTracker/DB/DatabaseInitializer.cs b/CodingTracker/DB/DatabaseInitializer.cs new file mode 100644 index 000000000..731181945 --- /dev/null +++ b/CodingTracker/DB/DatabaseInitializer.cs @@ -0,0 +1,26 @@ +using Dapper; +namespace CodeReviews.Console.CodingTracker; + +public sealed class DatabaseInitializer +{ + private readonly IDatabaseConnectionFactory _connectionFactory; + public DatabaseInitializer(IDatabaseConnectionFactory _cf) => _connectionFactory = _cf; + + public void Initialize() + { + const string sql = """ + CREATE TABLE IF NOT EXISTS CodingSessions + ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + StartTime TEXT NOT NULL, + EndTime TEXT NOT NULL + ); + """; + + using var connection = _connectionFactory.CreateConnection(); + + connection.Open(); + connection.Execute(sql); + connection.Close(); + } +} \ No newline at end of file diff --git a/CodingTracker/DB/IDatabaseConnectionFactory.cs b/CodingTracker/DB/IDatabaseConnectionFactory.cs new file mode 100644 index 000000000..707ab6a7d --- /dev/null +++ b/CodingTracker/DB/IDatabaseConnectionFactory.cs @@ -0,0 +1,7 @@ +using System.Data.Common; +namespace CodeReviews.Console.CodingTracker; + +public interface IDatabaseConnectionFactory +{ + DbConnection CreateConnection(); +} \ No newline at end of file diff --git a/CodingTracker/DB/SQLiteConnectionFactory.cs b/CodingTracker/DB/SQLiteConnectionFactory.cs new file mode 100644 index 000000000..5c0d439bf --- /dev/null +++ b/CodingTracker/DB/SQLiteConnectionFactory.cs @@ -0,0 +1,21 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; + +namespace CodeReviews.Console.CodingTracker; + +public sealed class SQLiteConnectionFactory : IDatabaseConnectionFactory +{ + private readonly string _connectionString; + + public SQLiteConnectionFactory(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException(); + + _connectionString = connectionString; + } + public DbConnection CreateConnection() + { + return new SqliteConnection(_connectionString); + } +} \ No newline at end of file diff --git a/CodingTracker/Enums/AddSessionOption.cs b/CodingTracker/Enums/AddSessionOption.cs new file mode 100644 index 000000000..5c7546c35 --- /dev/null +++ b/CodingTracker/Enums/AddSessionOption.cs @@ -0,0 +1,6 @@ +public enum AddSessionOption +{ + Manual, + Stopwatch, + Back, +} diff --git a/CodingTracker/Enums/FilterOption.cs b/CodingTracker/Enums/FilterOption.cs new file mode 100644 index 000000000..57c62ae3c --- /dev/null +++ b/CodingTracker/Enums/FilterOption.cs @@ -0,0 +1,9 @@ +public enum FilterOption +{ + ShowAll, + Day, + Week, + Month, + Year, + Back +} \ No newline at end of file diff --git a/CodingTracker/Enums/MenuOption.cs b/CodingTracker/Enums/MenuOption.cs new file mode 100644 index 000000000..53c142f0b --- /dev/null +++ b/CodingTracker/Enums/MenuOption.cs @@ -0,0 +1,9 @@ +public enum MenuOption +{ + View, + Add, + Edit, + Delete, + Close, +} + diff --git a/CodingTracker/Enums/Month.cs b/CodingTracker/Enums/Month.cs new file mode 100644 index 000000000..20d6d8bed --- /dev/null +++ b/CodingTracker/Enums/Month.cs @@ -0,0 +1,15 @@ +public enum Month +{ + January = 1, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December +} \ No newline at end of file diff --git a/CodingTracker/Model/CodingSession.cs b/CodingTracker/Model/CodingSession.cs new file mode 100644 index 000000000..b35d1113a --- /dev/null +++ b/CodingTracker/Model/CodingSession.cs @@ -0,0 +1,9 @@ +namespace CodeReviews.Console.CodingTracker; + +public class CodingSession +{ + public long Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration => EndTime - StartTime; +} diff --git a/CodingTracker/Model/CodingSessionDTO.cs b/CodingTracker/Model/CodingSessionDTO.cs new file mode 100644 index 000000000..a860822a1 --- /dev/null +++ b/CodingTracker/Model/CodingSessionDTO.cs @@ -0,0 +1,9 @@ +namespace CodeReviews.Console.CodingTracker; +// the version that is returned by SQL +public class CodingSessionDTO +{ + public long Id { get; set; } + public string? StartTime { get; set; } + public string? EndTime { get; set; } + +} \ No newline at end of file diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs new file mode 100644 index 000000000..88c04a51e --- /dev/null +++ b/CodingTracker/Program.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Configuration; + +namespace CodeReviews.Console.CodingTracker +{ + class Program + { + static void Main(string[] args) + { + IConfigurationRoot config = Setup(); + + string connectionString = config.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException( + "The DefaultConnection connection string is missing."); + + IDatabaseConnectionFactory connectionFactory = new SQLiteConnectionFactory(connectionString); + + var dbInitializer = new DatabaseInitializer(connectionFactory); + dbInitializer.Initialize(); + + ICodingSessionRepo repository = new CodingSessionRepo(connectionFactory); + ICodingSessionService service = new CodingSessionService(repository); + ICodingSessionView codingSessionView = new CodingSessionView(); + ICodingSessionController codingSessionController = new CodingSessionController(service, codingSessionView); + IAppView appView = new AppView(); + var appController = new AppController(appView, codingSessionController); + + appController.Run(); + } + + private static IConfigurationRoot Setup() + { + IConfigurationRoot config = + new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile( + "Configuration/appsettings.json", + optional: false) + .Build(); + + return config; + } + } +} \ No newline at end of file diff --git a/CodingTracker/Repositories/CodingSessionRepo.cs b/CodingTracker/Repositories/CodingSessionRepo.cs new file mode 100644 index 000000000..1631ab3e3 --- /dev/null +++ b/CodingTracker/Repositories/CodingSessionRepo.cs @@ -0,0 +1,320 @@ +// data access layer: write sql queries here +namespace CodeReviews.Console.CodingTracker; + +using Dapper; +using System.Globalization; + +public sealed class CodingSessionRepo : ICodingSessionRepo +{ + private const string _dateFormat = "yyyy-MM-dd HH:mm:ss"; + private readonly IDatabaseConnectionFactory _connectionFactory; + + public CodingSessionRepo(IDatabaseConnectionFactory _cf) => _connectionFactory = _cf; + + public List GetAll() + { + List sessions = new(); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public CodingSession? GetOne(long id) + { + CodingSession? session; + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE Id = @Id; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + var record = connection.QuerySingleOrDefault(query, new { Id = id }); + + session = record == null + ? null + : TryParseDateFromString(record.StartTime, out var startTime) && + TryParseDateFromString(record.EndTime, out var endTime) + ? new CodingSession + { + Id = record.Id, + StartTime = startTime, + EndTime = endTime, + } + : null; + } + + return session; + } + public void Add(CodingSession session) + { + if (session == null) + throw new ArgumentNullException(); + + const string query = @" + INSERT INTO CodingSessions (StartTime, EndTime) + VALUES (@StartTime, @EndTime) + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + var dto = new CodingSessionDTO + { + StartTime = FormatDateTimeToString(session.StartTime), + EndTime = FormatDateTimeToString(session.EndTime) + }; + connection.Open(); + connection.Execute(query, dto); + } + + } + public bool Delete(long id) + { + const string query = @" + DELETE FROM CodingSessions + WHERE Id = @Id; + "; + + int affectedRows; + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + affectedRows = connection.Execute(query, new { Id = id }); + } + return affectedRows == 1; + } + public bool Update(CodingSession session) + { + if (session == null) + throw new ArgumentNullException(); + + const string query = @" + UPDATE CodingSessions + SET StartTime = @StartTime, EndTime = @EndTime + WHERE Id = @Id; + "; + + int affectedRows; + using (var connection = _connectionFactory.CreateConnection()) + { + var dto = new CodingSessionDTO + { + Id = session.Id, + StartTime = FormatDateTimeToString(session.StartTime), + EndTime = FormatDateTimeToString(session.EndTime) + }; + connection.Open(); + affectedRows = connection.Execute(query, dto); + } + return affectedRows == 1; + } + public List GetSessionsByDay(DateTime day) + { + List sessions = new(); + + var start = day.Date; + var end = day.Date.AddDays(1); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public List GetSessionsInBetweenDates(DateTime start, DateTime end) + { + List sessions = new(); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public List GetSessionsByMonthOfYear(int year, int monthIndicator) + { + List sessions = new(); + + var start = new DateTime(year, monthIndicator, 1); + var end = start.AddMonths(1); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public List GetSessionsByYear(int year) + { + List sessions = new(); + + var start = new DateTime(year, 1, 1); + var end = start.AddYears(1); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + + private static bool TryParseDateFromString(string? value, out DateTime result) + { + result = default; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return DateTime.TryParseExact( + value, + _dateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out result); + } + + private static string FormatDateTimeToString(DateTime obj) + => obj.ToString(_dateFormat, CultureInfo.InvariantCulture); + +} \ No newline at end of file diff --git a/CodingTracker/Repositories/ICodingSessionRepo.cs b/CodingTracker/Repositories/ICodingSessionRepo.cs new file mode 100644 index 000000000..90cd17084 --- /dev/null +++ b/CodingTracker/Repositories/ICodingSessionRepo.cs @@ -0,0 +1,17 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface ICodingSessionRepo +{ + // CRUD methods + List GetAll(); + CodingSession? GetOne(long id); + void Add(CodingSession session); + bool Update(CodingSession session); + bool Delete(long id); + + // Filtering methods + List GetSessionsByDay(DateTime day); + List GetSessionsInBetweenDates(DateTime start, DateTime end); + List GetSessionsByMonthOfYear(int year, int monthIndicator); + List GetSessionsByYear(int year); +} \ No newline at end of file diff --git a/CodingTracker/Services/CodingSessionService.cs b/CodingTracker/Services/CodingSessionService.cs new file mode 100644 index 000000000..d97429953 --- /dev/null +++ b/CodingTracker/Services/CodingSessionService.cs @@ -0,0 +1,67 @@ +namespace CodeReviews.Console.CodingTracker; + +public sealed class CodingSessionService : ICodingSessionService +{ + private readonly ICodingSessionRepo _repository; + public CodingSessionService(ICodingSessionRepo repo) => _repository = repo; + + public void Add(DateTime startTime, DateTime endTime) + { + Validators.ValidateSessionTimes(startTime, endTime); + _repository.Add(new CodingSession + { + StartTime = startTime, + EndTime = endTime + }); + } + + public bool Delete(long id) => id <= 0 ? false : _repository.Delete(id); + + public List GetAll() => _repository.GetAll(); + + public CodingSession? GetOne(long id) => id <= 0 ? null : _repository.GetOne(id); + + public bool Update(long id, DateTime startTime, DateTime endTime) + { + if (id <= 0) return false; + + Validators.ValidateSessionTimes(startTime, endTime); + + CodingSession? session = + _repository.GetOne(id); + + if (session is null) return false; + + session.StartTime = startTime; + session.EndTime = endTime; + + return _repository.Update(session); + } + public List GetByDay(DateTime date) => _repository.GetSessionsByDay(date); + public List GetByWeek(DateTime date) + { + int daysSinceMonday = ((int)date.DayOfWeek + 6) % 7; + DateTime startOfWeek = date.Date.AddDays(-daysSinceMonday); + DateTime endOfWeek = startOfWeek.AddDays(7); + + return _repository.GetSessionsInBetweenDates(startOfWeek, endOfWeek); + } + + public List GetByMonthOfYear(int year, int month) + { + if (year < 1 || year > 9999) + throw new ArgumentOutOfRangeException(nameof(year)); + + if (month < 1 || month > 12) + throw new ArgumentOutOfRangeException(nameof(month)); + + return _repository.GetSessionsByMonthOfYear(year, month); + } + public List GetByYear(int year) + { + if (year < 1 || year > 9999) + throw new ArgumentOutOfRangeException(nameof(year)); + + return _repository.GetSessionsByYear(year); + } +} \ No newline at end of file diff --git a/CodingTracker/Services/ICodingSessionService.cs b/CodingTracker/Services/ICodingSessionService.cs new file mode 100644 index 000000000..ff8840ded --- /dev/null +++ b/CodingTracker/Services/ICodingSessionService.cs @@ -0,0 +1,14 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface ICodingSessionService +{ + List GetAll(); + CodingSession? GetOne(long id); + void Add(DateTime startTime, DateTime endTime); + bool Update(long id, DateTime startTime, DateTime endTime); + bool Delete(long id); + List GetByDay(DateTime date); + List GetByWeek(DateTime date); + List GetByMonthOfYear(int year, int month); + List GetByYear(int year); +} \ No newline at end of file diff --git a/CodingTracker/Validators/Validators.cs b/CodingTracker/Validators/Validators.cs new file mode 100644 index 000000000..6b5bdbb27 --- /dev/null +++ b/CodingTracker/Validators/Validators.cs @@ -0,0 +1,16 @@ +using CodeReviews.Console.CodingTracker; + +public static class Validators +{ + public static void ValidateSessionTimes( + DateTime startTime, + DateTime endTime) + { + if (endTime <= startTime) + { + throw new ArgumentException( + "The end time must be later than the start time."); + } + } + +} \ No newline at end of file diff --git a/CodingTracker/View/AppView.cs b/CodingTracker/View/AppView.cs new file mode 100644 index 000000000..e7ad80d04 --- /dev/null +++ b/CodingTracker/View/AppView.cs @@ -0,0 +1,36 @@ +using Spectre.Console; + +namespace CodeReviews.Console.CodingTracker; + +public sealed class AppView : IAppView +{ + public MenuOption DisplayMainMenu() + { + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]Coding Tracker[/]") + .AddChoices(Enum.GetValues()) + .UseConverter(FormatMenuOption)); + } + public void DisplayMessage(string message) + { + AnsiConsole.MarkupLine(Markup.Escape(message)); + } + public void DisplayGoodbye() + { + AnsiConsole.MarkupLine("[green]Goodbye![/]"); + } + private static string FormatMenuOption(MenuOption option) + { + return option switch + { + MenuOption.View => "View coding sessions", + MenuOption.Add => "Add coding session", + MenuOption.Edit => "Update coding session", + MenuOption.Delete => "Delete coding session", + MenuOption.Close => "Exit", + _ => option.ToString() + }; + } + +} diff --git a/CodingTracker/View/CodingSessionView.cs b/CodingTracker/View/CodingSessionView.cs new file mode 100644 index 000000000..7ea5feabc --- /dev/null +++ b/CodingTracker/View/CodingSessionView.cs @@ -0,0 +1,131 @@ + +using System.Globalization; +using Spectre.Console; +namespace CodeReviews.Console.CodingTracker; + +public sealed class CodingSessionView : ICodingSessionView +{ + private const string _inputDateFormat = "dd-MM-yyyy HH:mm"; + private const string _filterDateFormat = "dd-MM-yyyy"; + public void DisplayError(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + WaitForInput(); + } + + public void DisplayMessage(string message) + => AnsiConsole.MarkupLine($"[green]{Markup.Escape(message)}[/]"); + + public void DisplaySessions(List sessions) + { + AnsiConsole.Clear(); + + if (sessions.Count == 0) + { + DisplayMessage("No records found."); + return; + } + + var table = new Table() + .AddColumn("Id") + .AddColumn("Start time") + .AddColumn("End time") + .AddColumn("Duration"); + + foreach (var row in sessions) + { + table.AddRow( + row.Id.ToString(), + row.StartTime.ToString(_inputDateFormat), + row.EndTime.ToString(_inputDateFormat), + FormatDuration(row.Duration)); + } + + AnsiConsole.Write(table); + } + + public long GetSessionId(string prompt) + { + return AnsiConsole.Prompt( + new TextPrompt(prompt) + .ValidationErrorMessage( + "[red]Enter a valid numeric ID.[/]") + .Validate(id => + id > 0 + ? ValidationResult.Success() + : ValidationResult.Error( + "[red]The ID must be positive.[/]"))); + } + + public (DateTime StartTime, DateTime EndTime) GetSessionTimes() + { + DateTime startTime = GetDateTime("Enter the start time:", _inputDateFormat); + DateTime endTime = GetDateTime("Enter the end time:", _inputDateFormat); + + return (startTime, endTime); + } + + public Month GetMonth() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a month:") + .AddChoices(Enum.GetValues())); + + public int GetYear() + => AnsiConsole.Prompt( + new TextPrompt("Enter the year:") + .Validate(year => + year is >= 1 and <= 9999 + ? ValidationResult.Success() + : ValidationResult.Error( + "[red]Enter a valid year.[/]"))); + + public FilterOption DisplayFilterMenu() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("\nHow would you like to filter the sessions?") + .AddChoices(Enum.GetValues())); + + public DateTime GetFilterDate(string prompt) => GetDateTime(prompt, _filterDateFormat); + + public AddSessionOption DisplayAddSessionMenu() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How would you like to add a session?") + .AddChoices(Enum.GetValues())); + public void WaitStopwatch() + { + AnsiConsole.MarkupLine("[green]Timer started.[/] Press [yellow]Enter[/] to stop."); + while (System.Console.ReadKey(true).Key != ConsoleKey.Enter) ; + } + + private static DateTime GetDateTime(string prompt, string format) + { + while (true) + { + string input = AnsiConsole.Ask( + $"{prompt} [grey]({format})[/]"); + + if (DateTime.TryParseExact( + input, + format, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime result)) + { + return result; + } + + AnsiConsole.MarkupLine($"[red]Use the format {format}.[/]"); + } + } + private static string FormatDuration(TimeSpan duration) + => $"{(int)duration.TotalHours:D2}" + $":{duration.Minutes:D2}"; + + + private static void WaitForInput() + { + AnsiConsole.MarkupLine("\n[grey]Press any key to continue.[/]"); + AnsiConsole.Console.Input.ReadKey(true); + } +} \ No newline at end of file diff --git a/CodingTracker/View/IAppView.cs b/CodingTracker/View/IAppView.cs new file mode 100644 index 000000000..7eed58ace --- /dev/null +++ b/CodingTracker/View/IAppView.cs @@ -0,0 +1,8 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface IAppView +{ + MenuOption DisplayMainMenu(); + void DisplayMessage(string message); + void DisplayGoodbye(); +} \ No newline at end of file diff --git a/CodingTracker/View/ICodingSessionView.cs b/CodingTracker/View/ICodingSessionView.cs new file mode 100644 index 000000000..774f6e667 --- /dev/null +++ b/CodingTracker/View/ICodingSessionView.cs @@ -0,0 +1,16 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface ICodingSessionView +{ + void DisplaySessions(List sessions); + (DateTime StartTime, DateTime EndTime) GetSessionTimes(); + long GetSessionId(string prompt); + void DisplayMessage(string message); + void DisplayError(string message); + Month GetMonth(); + int GetYear(); + FilterOption DisplayFilterMenu(); + DateTime GetFilterDate(string prompt); + AddSessionOption DisplayAddSessionMenu(); + void WaitStopwatch(); +} \ No newline at end of file diff --git a/CodingTracker/coding-tracker.db b/CodingTracker/coding-tracker.db new file mode 100644 index 000000000..32cb20417 Binary files /dev/null and b/CodingTracker/coding-tracker.db differ diff --git a/README.md b/README.md new file mode 100644 index 000000000..1f3842e15 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# Coding Tracker App + +Console-based CRUD application for tracking coding sessions. Built with C#/.NET, SQLite, Dapper, and Spectre.Console. + +The project is based on [The C# Academy Coding Tracker challenge](https://www.thecsharpacademy.com/project/13/coding-tracker) and builds on the concepts I previously practised in my [Habit Tracker project](https://github.com/felikshetalia/CodeReviews.Console.HabitTracker). + +# Given Requirements + +- Users must be able to create, view, update, and delete coding sessions. +- Each coding session must contain an ID, start time, end time, and duration. +- Users enter the start and end times, while duration is calculated automatically. +- Date and time input must follow a clearly specified format and be validated. +- The application must store its data in a SQLite database. +- The database and the required table must be created automatically when the application starts. +- The database connection string must be stored in `appsettings.json`. +- Dapper must be used for database access. +- Spectre.Console must be used to display the user interface. +- The application must follow separation of concerns and keep classes in separate files. +- Repeated code should be reduced by following the DRY principle. +- Database records must be converted into `CodingSession` objects rather than being exposed to the rest of the application as anonymous data. +- The project should include a README explaining the implementation and thought process. + +# Features + +- **SQLite database initialization** + - The application creates the local database and `CodingSessions` table when they do not already exist. + - The database path is loaded from `Configuration/appsettings.json`. + - A connection factory centralizes the creation of SQLite connections. + +- **Coding-session CRUD operations** + - Add coding sessions. + - View all coding sessions. + - Update an existing session by ID. + - Delete an existing session by ID. + - Sessions are ordered from newest to oldest. + +- **Two ways to add a coding session** + - Enter a past session manually by providing its start and end times. + - Start a stopwatch and stop it when the coding session finishes. + +- **Automatic duration calculation** + - Users never enter duration manually. + - `Duration` is calculated from `EndTime - StartTime`, which prevents conflicting values from being stored. + +- **Filtering** + - Show all sessions. + - Filter by a specific day. + - Filter by week. + - Filter by month and year. + - Filter by year. + - Date-range queries use an inclusive start and exclusive end boundary. + +- **Input validation** + - Dates and times must match the expected format. + - The end time must be later than the start time. + - Session IDs must be positive. + - Update and delete workflows display the existing sessions before asking the user to choose an ID. + - Missing sessions and invalid operations are reported without terminating the application. + +- **Console interface** + - Spectre.Console selection menus are used instead of numeric menu commands. + - Coding sessions are presented in formatted tables. + - Error and success messages are displayed consistently. + +# Architecture + +The project uses an MVC-inspired layered structure with interfaces between the main dependencies: + +```text +Program +├── AppController +│ ├── IAppView +│ └── ICodingSessionController +│ +└── CodingSessionController + ├── ICodingSessionView + └── ICodingSessionService + └── CodingSessionService + └── ICodingSessionRepo + └── CodingSessionRepo + └── IDatabaseConnectionFactory + └── SQLiteConnectionFactory +``` + +The main responsibilities are: + +- **Views** handle Spectre.Console input and output. +- **Controllers** coordinate menu flows and connect views with services. +- **Services** contain validation, use-case logic, and date-range calculations. +- **Repositories** contain Dapper queries and database mapping. +- **Database classes** create connections and initialize the schema. +- **Models** represent coding sessions in the application. +- **Program.cs** acts as the composition root and connects concrete implementations through their interfaces. + +# Design Decisions + +- I kept `CodingSession.StartTime` and `CodingSession.EndTime` as `DateTime` values instead of changing the domain model to match SQLite's text representation. +- Conversion between `DateTime` and the database date format is handled at the persistence boundary. +- Duration is a calculated property rather than a separate database column. +- Filtering is placed inside the **View sessions** workflow instead of adding several extra options to the main menu. +- Week filtering accepts one date and calculates the Monday-to-Monday range in the service. +- I used a coding-session-specific repository interface instead of introducing a generic repository before another real repository exists. +- Interfaces are used at meaningful dependency boundaries rather than creating one interface for every class. +- Dependencies are constructed manually in `Program.cs` instead of introducing a dependency-injection framework for a small console application. + +# Technologies Used + +- C# +- .NET 9 +- SQLite +- Dapper +- Microsoft.Data.Sqlite +- Spectre.Console +- JSON configuration through `appsettings.json` +- NUnit 3 + +# Tests + +The solution contains both unit tests and database integration tests. + +- **Database initialization tests** + - Verify that the database and required table are created. + +- **Repository integration tests** + - Use isolated SQLite test databases. + - Cover create, read, update, and delete behavior. + - Verify that update and delete affect only the selected record. + - Test ordering and generated IDs. + - Test day, date-range, month, and year filters. + - Test inclusive and exclusive filter boundaries. + +- **Service unit tests** + - Use a test double implementing `ICodingSessionRepo`. + - Verify validation, invalid-ID short-circuiting, update behavior, repository delegation, and week-boundary calculations. + +- **Controller unit tests** + - Use fake services, views, and controllers. + - Verify menu routing, workflow branching, displayed messages, input order, filtering behavior, and error handling. + +Run the tests with: + +```bash +dotnet test CodingTracker/CodingTracker.sln +``` + +# Running the Application + +1. Clone the repository: + +```bash +git clone https://github.com/felikshetalia/CodeReviews.Console.CodingTracker.git +``` + +2. Move into the repository: + +```bash +cd CodeReviews.Console.CodingTracker +``` + +3. Restore the dependencies: + +```bash +dotnet restore CodingTracker/CodingTracker.sln +``` + +4. Run the application: + +```bash +dotnet run --project CodingTracker/CodingTracker.csproj +``` + +# What I've Learned from This Project + +- Using Dapper with SQLite for parameterized `SELECT`, `INSERT`, `UPDATE`, and `DELETE` operations. +- Creating a local SQLite database and schema automatically when the program starts. +- Loading connection settings from `appsettings.json`. +- Using a factory to isolate database-connection creation. +- Separating console presentation, application flow, business logic, and persistence. +- Applying interfaces and constructor injection to make classes replaceable and testable. +- Avoiding speculative base classes and generic abstractions until there is real duplication. +- Parsing and formatting dates consistently across console input and database storage. +- Calculating day, week, month, and year ranges using inclusive lower and exclusive upper boundaries. +- Building reusable Spectre.Console prompts and tables. +- Writing parameterized NUnit tests with `TestCase` and `TestCaseSource`. +- Distinguishing unit tests from repository integration tests. +- Creating handwritten test doubles that record calls and return configured results. +- Testing not only final results, but also whether dependencies were called with the correct arguments and in the correct order. + +# Areas to Improve + +- Inject a clock or `TimeProvider` so stopwatch tests do not depend on the system clock. +- Improve stopwatch recovery so an active timed session is not lost if the application closes unexpectedly. +- Make database error handling more consistent and avoid leaking SQLite-specific exceptions into higher layers. +- Reduce repeated database-row mapping and date-parsing code in the repository. +- Consider accepting seconds as optional manual input while preserving them for stopwatch sessions. +- Add automated test coverage reporting. +- Add screenshots or a short demonstration of the console interface.