diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..d4afd5a --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,43 @@ +name: Build and Test + +on: + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Install required workloads + run: dotnet workload restore SarahsDailyApp.slnx + + - name: Restore Android + run: dotnet restore SarahsDailyApp/SarahsDailyApp.csproj -p:TargetFrameworks=net10.0-android + + - name: Build Android + run: dotnet build SarahsDailyApp/SarahsDailyApp.csproj --no-restore --configuration Release -p:TargetFrameworks=net10.0-android + + - name: Restore Windows + run: dotnet restore SarahsDailyApp/SarahsDailyApp.csproj -p:TargetFrameworks=net10.0-windows10.0.19041.0 -r win-x64 + + - name: Build Windows + run: dotnet build SarahsDailyApp/SarahsDailyApp.csproj --no-restore --configuration Release -p:TargetFrameworks=net10.0-windows10.0.19041.0 -r win-x64 -p:WindowsPackageType=None + + - name: Run tests + run: dotnet test SarahsDailyApp.Tests/SarahsDailyApp.Tests.csproj --configuration Release --logger "trx;LogFileName=test-results.trx" --results-directory ./test-results + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: ./test-results/*.trx diff --git a/SarahsDailyApp.Tests/Converters/ConverterTests.cs b/SarahsDailyApp.Tests/Converters/ConverterTests.cs new file mode 100644 index 0000000..e977f8f --- /dev/null +++ b/SarahsDailyApp.Tests/Converters/ConverterTests.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using SarahsDailyApp.Converters; +using SarahsDailyApp.Models; + +namespace SarahsDailyApp.Tests.Converters; + +public class ConverterTests +{ + private static readonly CultureInfo Culture = CultureInfo.InvariantCulture; + + // --- InverseBoolConverter --- + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void InverseBoolConverter_Convert_InvertsBool(bool input, bool expected) + { + var converter = new InverseBoolConverter(); + var result = converter.Convert(input, typeof(bool), null, Culture); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void InverseBoolConverter_ConvertBack_InvertsBool(bool input, bool expected) + { + var converter = new InverseBoolConverter(); + var result = converter.ConvertBack(input, typeof(bool), null, Culture); + Assert.Equal(expected, result); + } + + [Fact] + public void InverseBoolConverter_Convert_NonBool_ReturnsValue() + { + var converter = new InverseBoolConverter(); + var result = converter.Convert("not a bool", typeof(bool), null, Culture); + Assert.Equal("not a bool", result); + } + + // --- BoolToStrikethroughConverter --- + + [Fact] + public void BoolToStrikethroughConverter_True_ReturnsStrikethrough() + { + var converter = new BoolToStrikethroughConverter(); + var result = converter.Convert(true, typeof(TextDecorations), null, Culture); + Assert.Equal(TextDecorations.Strikethrough, result); + } + + [Fact] + public void BoolToStrikethroughConverter_False_ReturnsNone() + { + var converter = new BoolToStrikethroughConverter(); + var result = converter.Convert(false, typeof(TextDecorations), null, Culture); + Assert.Equal(TextDecorations.None, result); + } + + [Fact] + public void BoolToStrikethroughConverter_NonBool_ReturnsNone() + { + var converter = new BoolToStrikethroughConverter(); + var result = converter.Convert("not a bool", typeof(TextDecorations), null, Culture); + Assert.Equal(TextDecorations.None, result); + } + + // --- PercentToProgressConverter --- + + [Theory] + [InlineData(0.0, 0.0)] + [InlineData(50.0, 0.5)] + [InlineData(100.0, 1.0)] + [InlineData(75.0, 0.75)] + public void PercentToProgressConverter_ConvertsCorrectly(double input, double expected) + { + var converter = new PercentToProgressConverter(); + var result = converter.Convert(input, typeof(double), null, Culture); + Assert.Equal(expected, result); + } + + [Fact] + public void PercentToProgressConverter_NonDouble_ReturnsZero() + { + var converter = new PercentToProgressConverter(); + var result = converter.Convert("not a double", typeof(double), null, Culture); + Assert.Equal(0.0, result); + } + + // --- IsNotNullConverter --- + + [Fact] + public void IsNotNullConverter_Null_ReturnsFalse() + { + var converter = new IsNotNullConverter(); + var result = converter.Convert(null, typeof(bool), null, Culture); + Assert.Equal(false, result); + } + + [Fact] + public void IsNotNullConverter_NonNull_ReturnsTrue() + { + var converter = new IsNotNullConverter(); + var result = converter.Convert(42, typeof(bool), null, Culture); + Assert.Equal(true, result); + } + + [Fact] + public void IsNotNullConverter_EmptyString_ReturnsFalse() + { + var converter = new IsNotNullConverter(); + var result = converter.Convert("", typeof(bool), null, Culture); + Assert.Equal(false, result); + } + + [Fact] + public void IsNotNullConverter_NonEmptyString_ReturnsTrue() + { + var converter = new IsNotNullConverter(); + var result = converter.Convert("hello", typeof(bool), null, Culture); + Assert.Equal(true, result); + } + + // --- IsNotZeroConverter --- + + [Fact] + public void IsNotZeroConverter_Zero_ReturnsFalse() + { + var converter = new IsNotZeroConverter(); + var result = converter.Convert(0, typeof(bool), null, Culture); + Assert.Equal(false, result); + } + + [Fact] + public void IsNotZeroConverter_NonZero_ReturnsTrue() + { + var converter = new IsNotZeroConverter(); + var result = converter.Convert(5, typeof(bool), null, Culture); + Assert.Equal(true, result); + } + + [Fact] + public void IsNotZeroConverter_NegativeNumber_ReturnsTrue() + { + var converter = new IsNotZeroConverter(); + var result = converter.Convert(-1, typeof(bool), null, Culture); + Assert.Equal(true, result); + } + + // --- IsNotNoneConverter --- + + [Fact] + public void IsNotNoneConverter_None_ReturnsFalse() + { + var converter = new IsNotNoneConverter(); + var result = converter.Convert(RepeatType.None, typeof(bool), null, Culture); + Assert.Equal(false, result); + } + + [Theory] + [InlineData(RepeatType.Daily)] + [InlineData(RepeatType.Weekly)] + [InlineData(RepeatType.Monthly)] + [InlineData(RepeatType.Yearly)] + [InlineData(RepeatType.Custom)] + [InlineData(RepeatType.Movable)] + public void IsNotNoneConverter_NonNone_ReturnsTrue(RepeatType repeatType) + { + var converter = new IsNotNoneConverter(); + var result = converter.Convert(repeatType, typeof(bool), null, Culture); + Assert.Equal(true, result); + } + + // --- StatusTextConverter --- + + [Fact] + public void StatusTextConverter_True_ReturnsCompleted() + { + var converter = new StatusTextConverter(); + var result = converter.Convert(true, typeof(string), null, Culture); + Assert.Equal("completed", result); + } + + [Fact] + public void StatusTextConverter_False_ReturnsPending() + { + var converter = new StatusTextConverter(); + var result = converter.Convert(false, typeof(string), null, Culture); + Assert.Equal("pending", result); + } + + // --- StatusColorConverter --- + + [Fact] + public void StatusColorConverter_True_ReturnsGreen() + { + var converter = new StatusColorConverter(); + var result = converter.Convert(true, typeof(Color), null, Culture); + Assert.IsType(result); + } + + [Fact] + public void StatusColorConverter_False_ReturnsOrange() + { + var converter = new StatusColorConverter(); + var result = converter.Convert(false, typeof(Color), null, Culture); + Assert.IsType(result); + } +} diff --git a/SarahsDailyApp.Tests/Helpers/TestDatabaseService.cs b/SarahsDailyApp.Tests/Helpers/TestDatabaseService.cs new file mode 100644 index 0000000..9a933e3 --- /dev/null +++ b/SarahsDailyApp.Tests/Helpers/TestDatabaseService.cs @@ -0,0 +1,56 @@ +using SQLite; +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; + +namespace SarahsDailyApp.Tests.Helpers; + +/// +/// A test-friendly DatabaseService that uses a unique temp file SQLite database +/// instead of the MAUI FileSystem.AppDataDirectory path. +/// +public class TestDatabaseService : DatabaseService, IDisposable +{ + private SQLiteAsyncConnection? _database; + private bool _initialized; + private readonly string _dbPath; + + public TestDatabaseService() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.db3"); + } + + public override async Task GetDatabaseAsync() + { + if (_initialized && _database is not null) + return _database; + + _database = new SQLiteAsyncConnection(_dbPath); + + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + await _database.CreateTableAsync(); + + // Seed defaults + await _database.InsertAsync(new UserStats()); + await _database.InsertAsync(new AppSettings()); + + _initialized = true; + return _database; + } + + public void Dispose() + { + _database?.CloseAsync().GetAwaiter().GetResult(); + try { File.Delete(_dbPath); } catch { } + GC.SuppressFinalize(this); + } +} diff --git a/SarahsDailyApp.Tests/Models/FinanceItemTests.cs b/SarahsDailyApp.Tests/Models/FinanceItemTests.cs new file mode 100644 index 0000000..bbe2da4 --- /dev/null +++ b/SarahsDailyApp.Tests/Models/FinanceItemTests.cs @@ -0,0 +1,57 @@ +using SarahsDailyApp.Models; + +namespace SarahsDailyApp.Tests.Models; + +public class FinanceItemTests +{ + [Fact] + public void NewFinanceItem_HasDefaultValues() + { + var item = new FinanceItem(); + + Assert.NotNull(item.Id); + Assert.NotEmpty(item.Id); + Assert.Equal(string.Empty, item.Description); + Assert.Equal(0m, item.Amount); + Assert.Null(item.Date); + Assert.Null(item.Category); + Assert.Equal(RecurringType.Once, item.Recurring); + } + + [Fact] + public void MonthlyAmount_OnceRecurring_ReturnsAmount() + { + var item = new FinanceItem { Amount = 120m, Recurring = RecurringType.Once }; + Assert.Equal(120m, item.MonthlyAmount); + } + + [Fact] + public void MonthlyAmount_MonthlyRecurring_ReturnsAmount() + { + var item = new FinanceItem { Amount = 50m, Recurring = RecurringType.Monthly }; + Assert.Equal(50m, item.MonthlyAmount); + } + + [Fact] + public void MonthlyAmount_YearlyRecurring_ReturnsDividedByTwelve() + { + var item = new FinanceItem { Amount = 1200m, Recurring = RecurringType.Yearly }; + Assert.Equal(100m, item.MonthlyAmount); + } + + [Fact] + public void MonthlyAmount_YearlyRecurring_HandlesNonEvenDivision() + { + var item = new FinanceItem { Amount = 100m, Recurring = RecurringType.Yearly }; + var expected = 100m / 12m; + Assert.Equal(expected, item.MonthlyAmount); + } + + [Fact] + public void Id_IsUnique_AcrossInstances() + { + var item1 = new FinanceItem(); + var item2 = new FinanceItem(); + Assert.NotEqual(item1.Id, item2.Id); + } +} diff --git a/SarahsDailyApp.Tests/Models/HabitTests.cs b/SarahsDailyApp.Tests/Models/HabitTests.cs new file mode 100644 index 0000000..d9cd938 --- /dev/null +++ b/SarahsDailyApp.Tests/Models/HabitTests.cs @@ -0,0 +1,67 @@ +using SarahsDailyApp.Models; + +namespace SarahsDailyApp.Tests.Models; + +public class HabitTests +{ + [Fact] + public void NewHabit_HasDefaultValues() + { + var habit = new Habit(); + + Assert.NotNull(habit.Id); + Assert.NotEmpty(habit.Id); + Assert.Equal(string.Empty, habit.Name); + Assert.Null(habit.Description); + Assert.Equal("⭐", habit.Icon); + Assert.Null(habit.Category); + Assert.Equal(1, habit.TargetGoal); + Assert.Equal("0,1,2,3,4,5,6", habit.DaysOfWeek); + Assert.Equal(0, habit.Streak); + Assert.Null(habit.LastCompletedDate); + } + + [Fact] + public void DaysOfWeekArray_Get_ReturnsAllDays_WhenDefault() + { + var habit = new Habit(); + Assert.Equal([0, 1, 2, 3, 4, 5, 6], habit.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Get_ReturnsAllDays_WhenEmpty() + { + var habit = new Habit { DaysOfWeek = "" }; + Assert.Equal([0, 1, 2, 3, 4, 5, 6], habit.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Get_ReturnsAllDays_WhenNull() + { + var habit = new Habit { DaysOfWeek = null! }; + Assert.Equal([0, 1, 2, 3, 4, 5, 6], habit.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Get_ParsesSpecificDays() + { + var habit = new Habit { DaysOfWeek = "1,3,5" }; + Assert.Equal([1, 3, 5], habit.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Set_StoresCommaSeparatedString() + { + var habit = new Habit(); + habit.DaysOfWeekArray = [0, 6]; + Assert.Equal("0,6", habit.DaysOfWeek); + } + + [Fact] + public void Id_IsUnique_AcrossInstances() + { + var habit1 = new Habit(); + var habit2 = new Habit(); + Assert.NotEqual(habit1.Id, habit2.Id); + } +} diff --git a/SarahsDailyApp.Tests/Models/OtherModelTests.cs b/SarahsDailyApp.Tests/Models/OtherModelTests.cs new file mode 100644 index 0000000..e7fdb41 --- /dev/null +++ b/SarahsDailyApp.Tests/Models/OtherModelTests.cs @@ -0,0 +1,101 @@ +using SarahsDailyApp.Models; + +namespace SarahsDailyApp.Tests.Models; + +public class OtherModelTests +{ + [Fact] + public void Project_HasDefaultValues() + { + var project = new Project(); + + Assert.NotNull(project.Id); + Assert.Equal(string.Empty, project.Name); + Assert.Null(project.Description); + Assert.Equal("#3b82f6", project.Color); + } + + [Fact] + public void HabitLog_HasDefaultValues() + { + var log = new HabitLog(); + + Assert.NotNull(log.Id); + Assert.Equal(string.Empty, log.HabitId); + } + + [Fact] + public void WishItem_HasDefaultValues() + { + var item = new WishItem(); + + Assert.NotNull(item.Id); + Assert.Equal(string.Empty, item.Title); + Assert.Null(item.Url); + Assert.Null(item.Price); + Assert.Equal(0, item.Order); + Assert.False(item.Completed); + Assert.Null(item.ListId); + } + + [Fact] + public void WishList_HasDefaultValues() + { + var list = new WishList(); + + Assert.NotNull(list.Id); + Assert.Equal(string.Empty, list.Name); + Assert.Equal(0, list.Order); + } + + [Fact] + public void Note_HasDefaultValues() + { + var note = new Note(); + + Assert.NotNull(note.Id); + Assert.Equal(string.Empty, note.Title); + Assert.Equal(string.Empty, note.Content); + Assert.Null(note.UpdatedDate); + } + + [Fact] + public void ShoppingItem_HasDefaultValues() + { + var item = new ShoppingItem(); + + Assert.NotNull(item.Id); + Assert.Equal(string.Empty, item.Name); + Assert.Null(item.Quantity); + Assert.False(item.Completed); + } + + [Fact] + public void UserStats_HasDefaultValues() + { + var stats = new UserStats(); + + Assert.Equal(1, stats.Id); + Assert.Equal(1, stats.Level); + Assert.Equal(0, stats.DailyStreak); + Assert.Null(stats.LastActivityDate); + } + + [Fact] + public void AppSettings_HasDefaultValues() + { + var settings = new AppSettings(); + + Assert.Equal(1, settings.Id); + Assert.Equal(30, settings.TasksPerLevel); + } + + [Fact] + public void Category_HasDefaultValues() + { + var category = new Category(); + + Assert.Equal(0, category.Id); + Assert.Equal(string.Empty, category.Name); + } +} diff --git a/SarahsDailyApp.Tests/Models/TaskItemTests.cs b/SarahsDailyApp.Tests/Models/TaskItemTests.cs new file mode 100644 index 0000000..22779a0 --- /dev/null +++ b/SarahsDailyApp.Tests/Models/TaskItemTests.cs @@ -0,0 +1,78 @@ +using SarahsDailyApp.Models; + +namespace SarahsDailyApp.Tests.Models; + +public class TaskItemTests +{ + [Fact] + public void NewTaskItem_HasDefaultValues() + { + var task = new TaskItem(); + + Assert.NotNull(task.Id); + Assert.NotEmpty(task.Id); + Assert.Equal(string.Empty, task.Title); + Assert.Null(task.Description); + Assert.Null(task.DueDate); + Assert.Equal(RepeatType.None, task.RepeatType); + Assert.Equal(1, task.RepeatUnit); + Assert.Null(task.CustomRepeatDays); + Assert.Null(task.MovableRepeatDays); + Assert.Null(task.DaysOfWeek); + Assert.Null(task.ProjectId); + Assert.False(task.Completed); + Assert.Null(task.CompletedDate); + } + + [Fact] + public void DaysOfWeekArray_Get_ReturnsNull_WhenDaysOfWeekIsNull() + { + var task = new TaskItem { DaysOfWeek = null }; + Assert.Null(task.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Get_ReturnsNull_WhenDaysOfWeekIsEmpty() + { + var task = new TaskItem { DaysOfWeek = "" }; + Assert.Null(task.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Get_ParsesCommaSeparatedValues() + { + var task = new TaskItem { DaysOfWeek = "1,3,5" }; + Assert.Equal([1, 3, 5], task.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Get_ParsesSingleValue() + { + var task = new TaskItem { DaysOfWeek = "0" }; + Assert.Equal([0], task.DaysOfWeekArray); + } + + [Fact] + public void DaysOfWeekArray_Set_StoresCommaSeparatedString() + { + var task = new TaskItem(); + task.DaysOfWeekArray = [0, 2, 4, 6]; + Assert.Equal("0,2,4,6", task.DaysOfWeek); + } + + [Fact] + public void DaysOfWeekArray_Set_Null_ClearsDaysOfWeek() + { + var task = new TaskItem { DaysOfWeek = "1,2,3" }; + task.DaysOfWeekArray = null; + Assert.Null(task.DaysOfWeek); + } + + [Fact] + public void Id_IsUnique_AcrossInstances() + { + var task1 = new TaskItem(); + var task2 = new TaskItem(); + Assert.NotEqual(task1.Id, task2.Id); + } +} diff --git a/SarahsDailyApp.Tests/SarahsDailyApp.Tests.csproj b/SarahsDailyApp.Tests/SarahsDailyApp.Tests.csproj new file mode 100644 index 0000000..96d0060 --- /dev/null +++ b/SarahsDailyApp.Tests/SarahsDailyApp.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0-windows10.0.19041.0 + enable + enable + false + Library + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SarahsDailyApp.Tests/Services/CategoryServiceTests.cs b/SarahsDailyApp.Tests/Services/CategoryServiceTests.cs new file mode 100644 index 0000000..04e31a8 --- /dev/null +++ b/SarahsDailyApp.Tests/Services/CategoryServiceTests.cs @@ -0,0 +1,197 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class CategoryServiceTests +{ + private static (CategoryService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new CategoryService(db); + return (service, db); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmpty_WhenNoCategories() + { + var (service, _) = CreateService(); + var categories = await service.GetAllAsync(); + Assert.Empty(categories); + } + + [Fact] + public async Task AddAsync_InsertsCategory() + { + var (service, _) = CreateService(); + + await service.AddAsync("Work"); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Work", all[0].Name); + } + + [Fact] + public async Task AddAsync_MultipleCategories_ReturnedAlphabetically() + { + var (service, _) = CreateService(); + await service.AddAsync("Zebra"); + await service.AddAsync("Apple"); + await service.AddAsync("Mango"); + + var all = await service.GetAllAsync(); + + Assert.Equal(3, all.Count); + Assert.Equal("Apple", all[0].Name); + Assert.Equal("Mango", all[1].Name); + Assert.Equal("Zebra", all[2].Name); + } + + [Fact] + public async Task RenameAsync_UpdatesCategoryName() + { + var (service, _) = CreateService(); + await service.AddAsync("OldName"); + + var all = await service.GetAllAsync(); + var category = all[0]; + + await service.RenameAsync(category.Id, "NewName"); + + var updated = await service.GetAllAsync(); + Assert.Single(updated); + Assert.Equal("NewName", updated[0].Name); + } + + [Fact] + public async Task RenameAsync_PropagatesRenameToHabits() + { + var db = new TestDatabaseService(); + var categoryService = new CategoryService(db); + var habitService = new HabitService(db); + + await categoryService.AddAsync("Fitness"); + var categories = await categoryService.GetAllAsync(); + var category = categories[0]; + + var habit = new Habit { Name = "Run", Category = "Fitness" }; + await habitService.SaveAsync(habit); + + await categoryService.RenameAsync(category.Id, "Exercise"); + + var updatedHabit = await habitService.GetByIdAsync(habit.Id); + Assert.NotNull(updatedHabit); + Assert.Equal("Exercise", updatedHabit.Category); + } + + [Fact] + public async Task RenameAsync_PropagatesRenameToFinanceItems() + { + var db = new TestDatabaseService(); + var categoryService = new CategoryService(db); + var financeService = new FinanceService(db); + + await categoryService.AddAsync("Food"); + var categories = await categoryService.GetAllAsync(); + var category = categories[0]; + + var item = new FinanceItem + { + Description = "Dinner", + Category = "Food", + Type = FinanceType.Expense + }; + await financeService.SaveAsync(item); + + await categoryService.RenameAsync(category.Id, "Dining"); + + var updatedItem = await financeService.GetByIdAsync(item.Id); + Assert.NotNull(updatedItem); + Assert.Equal("Dining", updatedItem.Category); + } + + [Fact] + public async Task RenameAsync_DoesNothing_WhenIdNotFound() + { + var (service, _) = CreateService(); + await service.AddAsync("Existing"); + + await service.RenameAsync(9999, "Something"); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Existing", all[0].Name); + } + + [Fact] + public async Task DeleteAsync_RemovesCategory() + { + var (service, _) = CreateService(); + await service.AddAsync("ToDelete"); + + var all = await service.GetAllAsync(); + await service.DeleteAsync(all[0].Id); + + var afterDelete = await service.GetAllAsync(); + Assert.Empty(afterDelete); + } + + [Fact] + public async Task DeleteAsync_ClearsCategoryOnHabits() + { + var db = new TestDatabaseService(); + var categoryService = new CategoryService(db); + var habitService = new HabitService(db); + + await categoryService.AddAsync("Health"); + var categories = await categoryService.GetAllAsync(); + + var habit = new Habit { Name = "Meditate", Category = "Health" }; + await habitService.SaveAsync(habit); + + await categoryService.DeleteAsync(categories[0].Id); + + var updatedHabit = await habitService.GetByIdAsync(habit.Id); + Assert.NotNull(updatedHabit); + Assert.Null(updatedHabit.Category); + } + + [Fact] + public async Task DeleteAsync_ClearsCategoryOnFinanceItems() + { + var db = new TestDatabaseService(); + var categoryService = new CategoryService(db); + var financeService = new FinanceService(db); + + await categoryService.AddAsync("Utilities"); + var categories = await categoryService.GetAllAsync(); + + var item = new FinanceItem + { + Description = "Electric Bill", + Category = "Utilities", + Type = FinanceType.Expense + }; + await financeService.SaveAsync(item); + + await categoryService.DeleteAsync(categories[0].Id); + + var updatedItem = await financeService.GetByIdAsync(item.Id); + Assert.NotNull(updatedItem); + Assert.Null(updatedItem.Category); + } + + [Fact] + public async Task DeleteAsync_DoesNothing_WhenIdNotFound() + { + var (service, _) = CreateService(); + await service.AddAsync("Existing"); + + await service.DeleteAsync(9999); + + var all = await service.GetAllAsync(); + Assert.Single(all); + } +} diff --git a/SarahsDailyApp.Tests/Services/ExportImportServiceTests.cs b/SarahsDailyApp.Tests/Services/ExportImportServiceTests.cs new file mode 100644 index 0000000..fc8ef7f --- /dev/null +++ b/SarahsDailyApp.Tests/Services/ExportImportServiceTests.cs @@ -0,0 +1,254 @@ +using System.Text.Json; +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class ExportImportServiceTests +{ + private static (ExportImportService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new ExportImportService(db); + return (service, db); + } + + [Fact] + public async Task ExportToJsonAsync_ReturnsValidJson() + { + var (service, _) = CreateService(); + + var json = await service.ExportToJsonAsync(); + + Assert.NotNull(json); + Assert.NotEmpty(json); + + var doc = JsonDocument.Parse(json); + Assert.Equal("1.0.0", doc.RootElement.GetProperty("version").GetString()); + Assert.Equal(3, doc.RootElement.GetProperty("schemaVersion").GetInt32()); + } + + [Fact] + public async Task ExportToJsonAsync_IncludesAllData() + { + var db = new TestDatabaseService(); + var taskService = new TaskService(db); + var exportService = new ExportImportService(db); + + await taskService.SaveAsync(new TaskItem { Title = "Test Task" }); + + var json = await exportService.ExportToJsonAsync(); + var doc = JsonDocument.Parse(json); + + var tasks = doc.RootElement.GetProperty("tasks"); + Assert.Equal(1, tasks.GetArrayLength()); + } + + [Fact] + public async Task ExportToJsonAsync_SeparatesFinancesByType() + { + var db = new TestDatabaseService(); + var financeService = new FinanceService(db); + var exportService = new ExportImportService(db); + + await financeService.SaveAsync(new FinanceItem { Description = "Exp", Type = FinanceType.Expense }); + await financeService.SaveAsync(new FinanceItem { Description = "Rev", Type = FinanceType.Revenue }); + await financeService.SaveAsync(new FinanceItem { Description = "Chg", Type = FinanceType.Charge }); + + var json = await exportService.ExportToJsonAsync(); + var doc = JsonDocument.Parse(json); + + Assert.Equal(1, doc.RootElement.GetProperty("expenses").GetArrayLength()); + Assert.Equal(1, doc.RootElement.GetProperty("revenue").GetArrayLength()); + Assert.Equal(1, doc.RootElement.GetProperty("charges").GetArrayLength()); + } + + [Fact] + public async Task ImportFromJsonAsync_ReturnsFalse_ForInvalidJson() + { + var (service, _) = CreateService(); + + var result = await service.ImportFromJsonAsync("not valid json"); + + Assert.False(result); + } + + [Fact] + public async Task ImportFromJsonAsync_ReturnsFalse_ForNullDeserialization() + { + var (service, _) = CreateService(); + + var result = await service.ImportFromJsonAsync("null"); + + Assert.False(result); + } + + [Fact] + public async Task ImportFromJsonAsync_ImportsTasks() + { + var db = new TestDatabaseService(); + var taskService = new TaskService(db); + var exportService = new ExportImportService(db); + + var json = """ + { + "version": "1.0.0", + "schemaVersion": 3, + "tasks": [ + { + "id": "task-1", + "title": "Imported Task", + "completed": false, + "repeatType": 0, + "repeatUnit": 1 + } + ] + } + """; + + var result = await exportService.ImportFromJsonAsync(json); + + Assert.True(result); + + var tasks = await taskService.GetAllAsync(); + Assert.Single(tasks); + Assert.Equal("Imported Task", tasks[0].Title); + } + + [Fact] + public async Task ImportFromJsonAsync_ClearsExistingData() + { + var db = new TestDatabaseService(); + var taskService = new TaskService(db); + var exportService = new ExportImportService(db); + + // Insert existing data + await taskService.SaveAsync(new TaskItem { Title = "Existing Task" }); + + var json = """ + { + "version": "1.0.0", + "schemaVersion": 3, + "tasks": [ + { + "id": "new-task-1", + "title": "New Task", + "completed": false, + "repeatType": 0, + "repeatUnit": 1 + } + ] + } + """; + + await exportService.ImportFromJsonAsync(json); + + var tasks = await taskService.GetAllAsync(); + Assert.Single(tasks); + Assert.Equal("New Task", tasks[0].Title); + } + + [Fact] + public async Task ImportFromJsonAsync_ImportsCategories() + { + var db = new TestDatabaseService(); + var categoryService = new CategoryService(db); + var exportService = new ExportImportService(db); + + var json = """ + { + "version": "1.0.0", + "schemaVersion": 3, + "categories": ["Work", "Personal", "Health"] + } + """; + + await exportService.ImportFromJsonAsync(json); + + var categories = await categoryService.GetAllAsync(); + Assert.Equal(3, categories.Count); + } + + [Fact] + public async Task RoundTrip_ExportThenImport_PreservesData() + { + var db = new TestDatabaseService(); + var taskService = new TaskService(db); + var noteService = new NoteService(db); + var exportService = new ExportImportService(db); + + // Create data + await taskService.SaveAsync(new TaskItem { Title = "Task A", Description = "Desc A" }); + await taskService.SaveAsync(new TaskItem { Title = "Task B", Completed = true }); + await noteService.SaveAsync(new Note { Title = "Note 1", Content = "Content 1" }); + + // Export + var json = await exportService.ExportToJsonAsync(); + + // Create a fresh database and import + var db2 = new TestDatabaseService(); + var taskService2 = new TaskService(db2); + var noteService2 = new NoteService(db2); + var exportService2 = new ExportImportService(db2); + + var result = await exportService2.ImportFromJsonAsync(json); + Assert.True(result); + + // Verify + var tasks = await taskService2.GetAllAsync(); + Assert.Equal(2, tasks.Count); + + var notes = await noteService2.GetAllAsync(); + Assert.Single(notes); + Assert.Equal("Note 1", notes[0].Title); + } + + [Fact] + public async Task ImportFromJsonAsync_HandlesEmptyCollections() + { + var (service, _) = CreateService(); + + var json = """ + { + "version": "1.0.0", + "schemaVersion": 3 + } + """; + + var result = await service.ImportFromJsonAsync(json); + + Assert.True(result); + } + + [Fact] + public async Task ImportFromJsonAsync_ImportsFinancesWithCorrectTypes() + { + var db = new TestDatabaseService(); + var financeService = new FinanceService(db); + var exportService = new ExportImportService(db); + + var json = """ + { + "version": "1.0.0", + "schemaVersion": 3, + "expenses": [{"id": "e1", "description": "Lunch", "amount": 15.00}], + "revenue": [{"id": "r1", "description": "Salary", "amount": 5000.00}], + "charges": [{"id": "c1", "description": "Netflix", "amount": 15.99}] + } + """; + + await exportService.ImportFromJsonAsync(json); + + var expenses = await financeService.GetByTypeAsync(FinanceType.Expense); + var revenues = await financeService.GetByTypeAsync(FinanceType.Revenue); + var charges = await financeService.GetByTypeAsync(FinanceType.Charge); + + Assert.Single(expenses); + Assert.Equal(FinanceType.Expense, expenses[0].Type); + Assert.Single(revenues); + Assert.Equal(FinanceType.Revenue, revenues[0].Type); + Assert.Single(charges); + Assert.Equal(FinanceType.Charge, charges[0].Type); + } +} diff --git a/SarahsDailyApp.Tests/Services/FinanceServiceTests.cs b/SarahsDailyApp.Tests/Services/FinanceServiceTests.cs new file mode 100644 index 0000000..bc42f6b --- /dev/null +++ b/SarahsDailyApp.Tests/Services/FinanceServiceTests.cs @@ -0,0 +1,235 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class FinanceServiceTests +{ + private static (FinanceService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new FinanceService(db); + return (service, db); + } + + [Fact] + public async Task GetByTypeAsync_ReturnsEmpty_WhenNoItems() + { + var (service, _) = CreateService(); + var items = await service.GetByTypeAsync(FinanceType.Expense); + Assert.Empty(items); + } + + [Fact] + public async Task SaveAsync_InsertsNewItem() + { + var (service, _) = CreateService(); + var item = new FinanceItem + { + Description = "Groceries", + Amount = 50m, + Type = FinanceType.Expense + }; + + await service.SaveAsync(item); + + var all = await service.GetByTypeAsync(FinanceType.Expense); + Assert.Single(all); + Assert.Equal("Groceries", all[0].Description); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingItem() + { + var (service, _) = CreateService(); + var item = new FinanceItem + { + Description = "Original", + Amount = 10m, + Type = FinanceType.Expense + }; + await service.SaveAsync(item); + + item.Description = "Updated"; + item.Amount = 20m; + await service.SaveAsync(item); + + var all = await service.GetByTypeAsync(FinanceType.Expense); + Assert.Single(all); + Assert.Equal("Updated", all[0].Description); + Assert.Equal(20m, all[0].Amount); + } + + [Fact] + public async Task GetByTypeAsync_FiltersCorrectly() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem { Description = "Exp1", Type = FinanceType.Expense }); + await service.SaveAsync(new FinanceItem { Description = "Rev1", Type = FinanceType.Revenue }); + await service.SaveAsync(new FinanceItem { Description = "Chg1", Type = FinanceType.Charge }); + await service.SaveAsync(new FinanceItem { Description = "Exp2", Type = FinanceType.Expense }); + + var expenses = await service.GetByTypeAsync(FinanceType.Expense); + var revenues = await service.GetByTypeAsync(FinanceType.Revenue); + var charges = await service.GetByTypeAsync(FinanceType.Charge); + + Assert.Equal(2, expenses.Count); + Assert.Single(revenues); + Assert.Single(charges); + } + + [Fact] + public async Task GetByIdAsync_ReturnsItem_WhenExists() + { + var (service, _) = CreateService(); + var item = new FinanceItem { Description = "Find Me", Type = FinanceType.Revenue }; + await service.SaveAsync(item); + + var found = await service.GetByIdAsync(item.Id); + + Assert.NotNull(found); + Assert.Equal("Find Me", found.Description); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotExists() + { + var (service, _) = CreateService(); + var found = await service.GetByIdAsync("nonexistent"); + Assert.Null(found); + } + + [Fact] + public async Task DeleteAsync_RemovesItem() + { + var (service, _) = CreateService(); + var item = new FinanceItem { Description = "Delete Me", Type = FinanceType.Expense }; + await service.SaveAsync(item); + + await service.DeleteAsync(item.Id); + + var all = await service.GetByTypeAsync(FinanceType.Expense); + Assert.Empty(all); + } + + [Fact] + public async Task GetFilteredAsync_OnceItem_InRange_IsIncluded() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem + { + Description = "In Range", + Type = FinanceType.Expense, + Recurring = RecurringType.Once, + Date = new DateTime(2025, 3, 15) + }); + + var results = await service.GetFilteredAsync( + FinanceType.Expense, + new DateTime(2025, 3, 1), + new DateTime(2025, 3, 31)); + + Assert.Single(results); + } + + [Fact] + public async Task GetFilteredAsync_OnceItem_OutOfRange_IsExcluded() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem + { + Description = "Out of Range", + Type = FinanceType.Expense, + Recurring = RecurringType.Once, + Date = new DateTime(2025, 4, 15) + }); + + var results = await service.GetFilteredAsync( + FinanceType.Expense, + new DateTime(2025, 3, 1), + new DateTime(2025, 3, 31)); + + Assert.Empty(results); + } + + [Fact] + public async Task GetFilteredAsync_MonthlyRecurring_IncludedIfDateBeforeEnd() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem + { + Description = "Monthly Subscription", + Type = FinanceType.Expense, + Recurring = RecurringType.Monthly, + Date = new DateTime(2025, 1, 1) + }); + + var results = await service.GetFilteredAsync( + FinanceType.Expense, + new DateTime(2025, 6, 1), + new DateTime(2025, 6, 30)); + + Assert.Single(results); + } + + [Fact] + public async Task GetFilteredAsync_YearlyRecurring_IncludedIfDateBeforeEnd() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem + { + Description = "Annual Fee", + Type = FinanceType.Expense, + Recurring = RecurringType.Yearly, + Date = new DateTime(2024, 1, 1) + }); + + var results = await service.GetFilteredAsync( + FinanceType.Expense, + new DateTime(2025, 6, 1), + new DateTime(2025, 6, 30)); + + Assert.Single(results); + } + + [Fact] + public async Task GetFilteredAsync_RecurringItem_ExcludedIfDateAfterEnd() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem + { + Description = "Future Monthly", + Type = FinanceType.Expense, + Recurring = RecurringType.Monthly, + Date = new DateTime(2025, 7, 1) + }); + + var results = await service.GetFilteredAsync( + FinanceType.Expense, + new DateTime(2025, 3, 1), + new DateTime(2025, 3, 31)); + + Assert.Empty(results); + } + + [Fact] + public async Task GetFilteredAsync_NullDate_IsExcluded() + { + var (service, _) = CreateService(); + await service.SaveAsync(new FinanceItem + { + Description = "No Date", + Type = FinanceType.Expense, + Recurring = RecurringType.Once, + Date = null + }); + + var results = await service.GetFilteredAsync( + FinanceType.Expense, + new DateTime(2025, 1, 1), + new DateTime(2025, 12, 31)); + + Assert.Empty(results); + } +} diff --git a/SarahsDailyApp.Tests/Services/HabitServiceTests.cs b/SarahsDailyApp.Tests/Services/HabitServiceTests.cs new file mode 100644 index 0000000..6e9c40f --- /dev/null +++ b/SarahsDailyApp.Tests/Services/HabitServiceTests.cs @@ -0,0 +1,245 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class HabitServiceTests +{ + private static (HabitService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new HabitService(db); + return (service, db); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmpty_WhenNoHabits() + { + var (service, _) = CreateService(); + var habits = await service.GetAllAsync(); + Assert.Empty(habits); + } + + [Fact] + public async Task SaveAsync_InsertsNewHabit() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Exercise" }; + + await service.SaveAsync(habit); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Exercise", all[0].Name); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingHabit() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Original" }; + await service.SaveAsync(habit); + + habit.Name = "Updated"; + await service.SaveAsync(habit); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Updated", all[0].Name); + } + + [Fact] + public async Task GetByIdAsync_ReturnsHabit_WhenExists() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Find Me" }; + await service.SaveAsync(habit); + + var found = await service.GetByIdAsync(habit.Id); + + Assert.NotNull(found); + Assert.Equal("Find Me", found.Name); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotExists() + { + var (service, _) = CreateService(); + var found = await service.GetByIdAsync("nonexistent"); + Assert.Null(found); + } + + [Fact] + public async Task DeleteAsync_RemovesHabitAndLogs() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Delete Me" }; + await service.SaveAsync(habit); + + // Complete the habit to create a log + await service.CompleteHabitAsync(habit, DateTime.Today); + + await service.DeleteAsync(habit.Id); + + var all = await service.GetAllAsync(); + Assert.Empty(all); + + var logs = await service.GetLogsForHabitAsync(habit.Id); + Assert.Empty(logs); + } + + [Fact] + public async Task CompleteHabitAsync_CreatesLog() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Complete Me" }; + await service.SaveAsync(habit); + + var selectedDate = new DateTime(2025, 3, 15); + await service.CompleteHabitAsync(habit, selectedDate); + + var logs = await service.GetLogsForHabitAsync(habit.Id); + Assert.Single(logs); + Assert.Equal(selectedDate.Date, logs[0].Date); + Assert.Equal(habit.Id, logs[0].HabitId); + } + + [Fact] + public async Task CompleteHabitAsync_UpdatesLastCompletedDate() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Track Date" }; + await service.SaveAsync(habit); + + var selectedDate = new DateTime(2025, 3, 15); + await service.CompleteHabitAsync(habit, selectedDate); + + var updated = await service.GetByIdAsync(habit.Id); + Assert.NotNull(updated); + Assert.Equal(selectedDate.Date, updated.LastCompletedDate); + } + + [Fact] + public async Task CompleteHabitAsync_CalculatesStreak() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Streak Habit", TargetGoal = 1 }; + await service.SaveAsync(habit); + + // Complete 3 consecutive days + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 13)); + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 14)); + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 15)); + + var updated = await service.GetByIdAsync(habit.Id); + Assert.NotNull(updated); + Assert.Equal(3, updated.Streak); + } + + [Fact] + public async Task CalculateStreakAsync_ReturnsZero_WhenNoLogs() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "No Logs" }; + await service.SaveAsync(habit); + + var streak = await service.CalculateStreakAsync(habit); + + Assert.Equal(0, streak); + } + + [Fact] + public async Task CalculateStreakAsync_ReturnsSingleDay_WhenOneCompletion() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "One Day", TargetGoal = 1 }; + await service.SaveAsync(habit); + + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 15)); + + var streak = await service.CalculateStreakAsync(habit); + + Assert.Equal(1, streak); + } + + [Fact] + public async Task CalculateStreakAsync_BreaksOnGap() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Gap Habit", TargetGoal = 1 }; + await service.SaveAsync(habit); + + // Day 1, 2, skip 3, day 4, 5 + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 11)); + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 12)); + // Skip March 13 + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 14)); + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 15)); + + var streak = await service.CalculateStreakAsync(habit); + + // Only the last 2 consecutive days count + Assert.Equal(2, streak); + } + + [Fact] + public async Task CalculateStreakAsync_RequiresTargetGoal() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Double Goal", TargetGoal = 2 }; + await service.SaveAsync(habit); + + // Day 1: complete twice (meets goal) + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 14)); + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 14)); + + // Day 2: complete once (doesn't meet goal of 2) + await service.CompleteHabitAsync(habit, new DateTime(2025, 3, 15)); + + var streak = await service.CalculateStreakAsync(habit); + + // Only day 1 met the goal; day 2 didn't + // But since day 2 is more recent and didn't meet goal, streak from most recent is 0 + // Actually: groupBy date, filter by count>=targetGoal, then count consecutive from most recent + // Day 15 has 1 completion (< 2 goal), so filtered out + // Day 14 has 2 completions (>= 2 goal), streak = 1 + Assert.Equal(1, streak); + } + + [Fact] + public async Task GetCompletionsForDateAsync_ReturnsCount() + { + var (service, _) = CreateService(); + var habit = new Habit { Name = "Count Completions" }; + await service.SaveAsync(habit); + + var date = new DateTime(2025, 3, 15); + await service.CompleteHabitAsync(habit, date); + await service.CompleteHabitAsync(habit, date); + await service.CompleteHabitAsync(habit, date.AddDays(1)); + + var count = await service.GetCompletionsForDateAsync(habit.Id, date); + + Assert.Equal(2, count); + } + + [Fact] + public async Task GetLogsForDateAsync_ReturnsLogsForSpecificDate() + { + var (service, _) = CreateService(); + var habit1 = new Habit { Name = "Habit A" }; + var habit2 = new Habit { Name = "Habit B" }; + await service.SaveAsync(habit1); + await service.SaveAsync(habit2); + + var date = new DateTime(2025, 3, 15); + await service.CompleteHabitAsync(habit1, date); + await service.CompleteHabitAsync(habit2, date); + await service.CompleteHabitAsync(habit1, date.AddDays(1)); + + var logs = await service.GetLogsForDateAsync(date); + + Assert.Equal(2, logs.Count); + } +} diff --git a/SarahsDailyApp.Tests/Services/NoteServiceTests.cs b/SarahsDailyApp.Tests/Services/NoteServiceTests.cs new file mode 100644 index 0000000..6b49e5a --- /dev/null +++ b/SarahsDailyApp.Tests/Services/NoteServiceTests.cs @@ -0,0 +1,126 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class NoteServiceTests +{ + private static (NoteService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new NoteService(db); + return (service, db); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmpty_WhenNoNotes() + { + var (service, _) = CreateService(); + var notes = await service.GetAllAsync(); + Assert.Empty(notes); + } + + [Fact] + public async Task SaveAsync_InsertsNewNote() + { + var (service, _) = CreateService(); + var note = new Note { Title = "My Note", Content = "Hello World" }; + + await service.SaveAsync(note); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("My Note", all[0].Title); + Assert.Equal("Hello World", all[0].Content); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingNote_AndSetsUpdatedDate() + { + var (service, _) = CreateService(); + var note = new Note { Title = "Original", Content = "Content" }; + await service.SaveAsync(note); + + Assert.Null(note.UpdatedDate); + + note.Title = "Updated"; + await service.SaveAsync(note); + + var updated = await service.GetByIdAsync(note.Id); + Assert.NotNull(updated); + Assert.Equal("Updated", updated.Title); + Assert.NotNull(updated.UpdatedDate); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNote_WhenExists() + { + var (service, _) = CreateService(); + var note = new Note { Title = "Find Me" }; + await service.SaveAsync(note); + + var found = await service.GetByIdAsync(note.Id); + + Assert.NotNull(found); + Assert.Equal("Find Me", found.Title); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotExists() + { + var (service, _) = CreateService(); + var found = await service.GetByIdAsync("nonexistent"); + Assert.Null(found); + } + + [Fact] + public async Task DeleteAsync_RemovesNote() + { + var (service, _) = CreateService(); + var note = new Note { Title = "Delete Me" }; + await service.SaveAsync(note); + + await service.DeleteAsync(note.Id); + + var all = await service.GetAllAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task GetAllAsync_ReturnsOrderedByUpdatedDateDescending() + { + var (service, _) = CreateService(); + + var note1 = new Note + { + Title = "First", + Content = "Content 1", + CreatedDate = new DateTime(2025, 1, 1) + }; + var note2 = new Note + { + Title = "Second", + Content = "Content 2", + CreatedDate = new DateTime(2025, 1, 2) + }; + var note3 = new Note + { + Title = "Third", + Content = "Content 3", + CreatedDate = new DateTime(2025, 1, 3), + UpdatedDate = new DateTime(2025, 1, 10) + }; + + await service.SaveAsync(note1); + await service.SaveAsync(note2); + await service.SaveAsync(note3); + + var all = await service.GetAllAsync(); + + // note3 has UpdatedDate (Jan 10) > note2 CreatedDate (Jan 2) > note1 CreatedDate (Jan 1) + Assert.Equal("Third", all[0].Title); + Assert.Equal("Second", all[1].Title); + Assert.Equal("First", all[2].Title); + } +} diff --git a/SarahsDailyApp.Tests/Services/ProjectServiceTests.cs b/SarahsDailyApp.Tests/Services/ProjectServiceTests.cs new file mode 100644 index 0000000..cfcbd8f --- /dev/null +++ b/SarahsDailyApp.Tests/Services/ProjectServiceTests.cs @@ -0,0 +1,116 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class ProjectServiceTests +{ + private static (ProjectService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new ProjectService(db); + return (service, db); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmpty_WhenNoProjects() + { + var (service, _) = CreateService(); + var projects = await service.GetAllAsync(); + Assert.Empty(projects); + } + + [Fact] + public async Task SaveAsync_InsertsNewProject() + { + var (service, _) = CreateService(); + var project = new Project { Name = "My Project" }; + + await service.SaveAsync(project); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("My Project", all[0].Name); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingProject() + { + var (service, _) = CreateService(); + var project = new Project { Name = "Original" }; + await service.SaveAsync(project); + + project.Name = "Updated"; + await service.SaveAsync(project); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Updated", all[0].Name); + } + + [Fact] + public async Task GetByIdAsync_ReturnsProject_WhenExists() + { + var (service, _) = CreateService(); + var project = new Project { Name = "Find Me" }; + await service.SaveAsync(project); + + var found = await service.GetByIdAsync(project.Id); + + Assert.NotNull(found); + Assert.Equal("Find Me", found.Name); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotExists() + { + var (service, _) = CreateService(); + var found = await service.GetByIdAsync("nonexistent"); + Assert.Null(found); + } + + [Fact] + public async Task DeleteAsync_RemovesProject() + { + var (service, _) = CreateService(); + var project = new Project { Name = "Delete Me" }; + await service.SaveAsync(project); + + await service.DeleteAsync(project.Id); + + var all = await service.GetAllAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task DeleteAsync_UnlinksTasksFromProject() + { + var db = new TestDatabaseService(); + var projectService = new ProjectService(db); + var taskService = new TaskService(db); + + var project = new Project { Name = "Project With Tasks" }; + await projectService.SaveAsync(project); + + var task1 = new TaskItem { Title = "Task 1", ProjectId = project.Id }; + var task2 = new TaskItem { Title = "Task 2", ProjectId = project.Id }; + var task3 = new TaskItem { Title = "Task 3", ProjectId = "other-project" }; + await taskService.SaveAsync(task1); + await taskService.SaveAsync(task2); + await taskService.SaveAsync(task3); + + await projectService.DeleteAsync(project.Id); + + var updatedTask1 = await taskService.GetByIdAsync(task1.Id); + var updatedTask2 = await taskService.GetByIdAsync(task2.Id); + var updatedTask3 = await taskService.GetByIdAsync(task3.Id); + + Assert.NotNull(updatedTask1); + Assert.Null(updatedTask1.ProjectId); + Assert.NotNull(updatedTask2); + Assert.Null(updatedTask2.ProjectId); + Assert.NotNull(updatedTask3); + Assert.Equal("other-project", updatedTask3.ProjectId); + } +} diff --git a/SarahsDailyApp.Tests/Services/ShoppingServiceTests.cs b/SarahsDailyApp.Tests/Services/ShoppingServiceTests.cs new file mode 100644 index 0000000..eb4117a --- /dev/null +++ b/SarahsDailyApp.Tests/Services/ShoppingServiceTests.cs @@ -0,0 +1,133 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class ShoppingServiceTests +{ + private static (ShoppingService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new ShoppingService(db); + return (service, db); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmpty_WhenNoItems() + { + var (service, _) = CreateService(); + var items = await service.GetAllAsync(); + Assert.Empty(items); + } + + [Fact] + public async Task SaveAsync_InsertsNewItem() + { + var (service, _) = CreateService(); + var item = new ShoppingItem { Name = "Milk", Quantity = "1 gallon" }; + + await service.SaveAsync(item); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Milk", all[0].Name); + Assert.Equal("1 gallon", all[0].Quantity); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingItem() + { + var (service, _) = CreateService(); + var item = new ShoppingItem { Name = "Original" }; + await service.SaveAsync(item); + + item.Name = "Updated"; + await service.SaveAsync(item); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Updated", all[0].Name); + } + + [Fact] + public async Task DeleteAsync_RemovesItem() + { + var (service, _) = CreateService(); + var item = new ShoppingItem { Name = "Delete Me" }; + await service.SaveAsync(item); + + await service.DeleteAsync(item.Id); + + var all = await service.GetAllAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task ToggleCompletionAsync_TogglesState() + { + var (service, _) = CreateService(); + var item = new ShoppingItem { Name = "Toggle", Completed = false }; + await service.SaveAsync(item); + + await service.ToggleCompletionAsync(item); + + var all = await service.GetAllAsync(); + Assert.True(all[0].Completed); + } + + [Fact] + public async Task ToggleCompletionAsync_TogglesBack() + { + var (service, _) = CreateService(); + var item = new ShoppingItem { Name = "Toggle Back", Completed = false }; + await service.SaveAsync(item); + + await service.ToggleCompletionAsync(item); + await service.ToggleCompletionAsync(item); + + var all = await service.GetAllAsync(); + Assert.False(all[0].Completed); + } + + [Fact] + public async Task ClearCheckedAsync_DeletesOnlyCompletedItems() + { + var (service, _) = CreateService(); + await service.SaveAsync(new ShoppingItem { Name = "Checked 1", Completed = true }); + await service.SaveAsync(new ShoppingItem { Name = "Checked 2", Completed = true }); + await service.SaveAsync(new ShoppingItem { Name = "Unchecked", Completed = false }); + + await service.ClearCheckedAsync(); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Unchecked", all[0].Name); + } + + [Fact] + public async Task ClearCheckedAsync_NoOp_WhenNoneChecked() + { + var (service, _) = CreateService(); + await service.SaveAsync(new ShoppingItem { Name = "Item 1", Completed = false }); + await service.SaveAsync(new ShoppingItem { Name = "Item 2", Completed = false }); + + await service.ClearCheckedAsync(); + + var all = await service.GetAllAsync(); + Assert.Equal(2, all.Count); + } + + [Fact] + public async Task ClearCheckedAsync_ClearsAll_WhenAllChecked() + { + var (service, _) = CreateService(); + await service.SaveAsync(new ShoppingItem { Name = "Item 1", Completed = true }); + await service.SaveAsync(new ShoppingItem { Name = "Item 2", Completed = true }); + + await service.ClearCheckedAsync(); + + var all = await service.GetAllAsync(); + Assert.Empty(all); + } +} diff --git a/SarahsDailyApp.Tests/Services/TaskServiceTests.cs b/SarahsDailyApp.Tests/Services/TaskServiceTests.cs new file mode 100644 index 0000000..5403cec --- /dev/null +++ b/SarahsDailyApp.Tests/Services/TaskServiceTests.cs @@ -0,0 +1,421 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class TaskServiceTests +{ + private static (TaskService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new TaskService(db); + return (service, db); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmpty_WhenNoTasks() + { + var (service, _) = CreateService(); + var tasks = await service.GetAllAsync(); + Assert.Empty(tasks); + } + + [Fact] + public async Task SaveAsync_InsertsNewTask() + { + var (service, _) = CreateService(); + var task = new TaskItem { Title = "Test Task" }; + + await service.SaveAsync(task); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Test Task", all[0].Title); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingTask() + { + var (service, _) = CreateService(); + var task = new TaskItem { Title = "Original" }; + await service.SaveAsync(task); + + task.Title = "Updated"; + await service.SaveAsync(task); + + var all = await service.GetAllAsync(); + Assert.Single(all); + Assert.Equal("Updated", all[0].Title); + } + + [Fact] + public async Task GetByIdAsync_ReturnsTask_WhenExists() + { + var (service, _) = CreateService(); + var task = new TaskItem { Title = "Find Me" }; + await service.SaveAsync(task); + + var found = await service.GetByIdAsync(task.Id); + + Assert.NotNull(found); + Assert.Equal("Find Me", found.Title); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotExists() + { + var (service, _) = CreateService(); + var found = await service.GetByIdAsync("nonexistent"); + Assert.Null(found); + } + + [Fact] + public async Task GetByProjectIdAsync_ReturnsMatchingTasks() + { + var (service, _) = CreateService(); + var projectId = "proj-1"; + await service.SaveAsync(new TaskItem { Title = "Task A", ProjectId = projectId }); + await service.SaveAsync(new TaskItem { Title = "Task B", ProjectId = projectId }); + await service.SaveAsync(new TaskItem { Title = "Task C", ProjectId = "other" }); + + var tasks = await service.GetByProjectIdAsync(projectId); + + Assert.Equal(2, tasks.Count); + Assert.All(tasks, t => Assert.Equal(projectId, t.ProjectId)); + } + + [Fact] + public async Task DeleteAsync_RemovesTask() + { + var (service, _) = CreateService(); + var task = new TaskItem { Title = "To Delete" }; + await service.SaveAsync(task); + + await service.DeleteAsync(task.Id); + + var all = await service.GetAllAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task GetCompletedCountAsync_ReturnsCount() + { + var (service, _) = CreateService(); + await service.SaveAsync(new TaskItem { Title = "Done 1", Completed = true }); + await service.SaveAsync(new TaskItem { Title = "Done 2", Completed = true }); + await service.SaveAsync(new TaskItem { Title = "Not Done", Completed = false }); + + var count = await service.GetCompletedCountAsync(); + + Assert.Equal(2, count); + } + + [Fact] + public async Task ToggleCompletionAsync_CompletesTask() + { + var (service, _) = CreateService(); + var task = new TaskItem { Title = "Toggle Me", Completed = false }; + await service.SaveAsync(task); + + await service.ToggleCompletionAsync(task, DateTime.Today); + + var updated = await service.GetByIdAsync(task.Id); + Assert.NotNull(updated); + Assert.True(updated.Completed); + Assert.Equal(DateTime.Today.Date, updated.CompletedDate); + } + + [Fact] + public async Task ToggleCompletionAsync_UncompletesTask() + { + var (service, _) = CreateService(); + var task = new TaskItem { Title = "Toggle Me", Completed = true, CompletedDate = DateTime.Today }; + await service.SaveAsync(task); + + await service.ToggleCompletionAsync(task, DateTime.Today); + + var updated = await service.GetByIdAsync(task.Id); + Assert.NotNull(updated); + Assert.False(updated.Completed); + Assert.Null(updated.CompletedDate); + } + + [Fact] + public async Task ToggleCompletionAsync_CreatesRecurringTask_WhenCompleted() + { + var (service, _) = CreateService(); + var task = new TaskItem + { + Title = "Daily Task", + RepeatType = RepeatType.Daily, + RepeatUnit = 1, + Completed = false + }; + await service.SaveAsync(task); + + await service.ToggleCompletionAsync(task, DateTime.Today); + + var all = await service.GetAllAsync(); + Assert.Equal(2, all.Count); + var newTask = all.First(t => t.Id != task.Id); + Assert.Equal("Daily Task", newTask.Title); + Assert.False(newTask.Completed); + Assert.Equal(RepeatType.Daily, newTask.RepeatType); + } + + [Fact] + public async Task ToggleCompletionAsync_DoesNotCreateRecurring_WhenUncompleted() + { + var (service, _) = CreateService(); + var task = new TaskItem + { + Title = "Daily Task", + RepeatType = RepeatType.Daily, + Completed = true, + CompletedDate = DateTime.Today + }; + await service.SaveAsync(task); + + await service.ToggleCompletionAsync(task, DateTime.Today); + + var all = await service.GetAllAsync(); + Assert.Single(all); + } + + // --- CreateNextRecurringTask tests (static method) --- + + [Fact] + public void CreateNextRecurringTask_Daily_AddsDays() + { + var original = new TaskItem + { + Title = "Daily", + RepeatType = RepeatType.Daily, + RepeatUnit = 1, + DueDate = new DateTime(2025, 1, 1) + }; + var completionDate = new DateTime(2025, 1, 1); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 2), next.DueDate); + Assert.NotEqual(original.Id, next.Id); + Assert.Equal("Daily", next.Title); + Assert.False(next.Completed); + Assert.Null(next.CompletedDate); + } + + [Fact] + public void CreateNextRecurringTask_Daily_WithUnit3_Adds3Days() + { + var original = new TaskItem + { + Title = "Every 3 Days", + RepeatType = RepeatType.Daily, + RepeatUnit = 3 + }; + var completionDate = new DateTime(2025, 1, 10); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 13), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Weekly_Adds7Days() + { + var original = new TaskItem + { + Title = "Weekly", + RepeatType = RepeatType.Weekly, + RepeatUnit = 1 + }; + var completionDate = new DateTime(2025, 1, 6); // Monday + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 13), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Weekly_WithUnit2_Adds14Days() + { + var original = new TaskItem + { + Title = "Bi-Weekly", + RepeatType = RepeatType.Weekly, + RepeatUnit = 2 + }; + var completionDate = new DateTime(2025, 1, 6); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 20), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Monthly_AddsMonth() + { + var original = new TaskItem + { + Title = "Monthly", + RepeatType = RepeatType.Monthly, + RepeatUnit = 1 + }; + var completionDate = new DateTime(2025, 1, 15); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 2, 15), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Monthly_WithUnit3_Adds3Months() + { + var original = new TaskItem + { + Title = "Quarterly", + RepeatType = RepeatType.Monthly, + RepeatUnit = 3 + }; + var completionDate = new DateTime(2025, 1, 15); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 4, 15), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Yearly_AddsYear() + { + var original = new TaskItem + { + Title = "Yearly", + RepeatType = RepeatType.Yearly, + RepeatUnit = 1 + }; + var completionDate = new DateTime(2025, 6, 15); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2026, 6, 15), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Custom_AddsCustomDays() + { + var original = new TaskItem + { + Title = "Custom", + RepeatType = RepeatType.Custom, + CustomRepeatDays = 10 + }; + var completionDate = new DateTime(2025, 1, 1); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 11), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Custom_DefaultsTo1Day_WhenNull() + { + var original = new TaskItem + { + Title = "Custom No Days", + RepeatType = RepeatType.Custom, + CustomRepeatDays = null + }; + var completionDate = new DateTime(2025, 1, 1); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 2), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Movable_AddsMovableDays() + { + var original = new TaskItem + { + Title = "Movable", + RepeatType = RepeatType.Movable, + MovableRepeatDays = 5 + }; + var completionDate = new DateTime(2025, 1, 1); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(new DateTime(2025, 1, 6), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Daily_WithDaysOfWeek_FindsNextMatchingDay() + { + // Jan 6, 2025 is Monday (DayOfWeek = 1) + // DaysOfWeek = "3,5" means Wed and Fri + var original = new TaskItem + { + Title = "Weekday Task", + RepeatType = RepeatType.Daily, + RepeatUnit = 1, + DaysOfWeek = "3,5" // Wed, Fri + }; + var completionDate = new DateTime(2025, 1, 6); // Monday + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + // Next Wednesday after Monday is Jan 8 + Assert.Equal(new DateTime(2025, 1, 8), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_Weekly_WithDaysOfWeek_FindsNextOccurrence() + { + // Jan 8, 2025 is Wednesday (DayOfWeek = 3) + // DaysOfWeek = "1" means Monday + var original = new TaskItem + { + Title = "Weekly Monday", + RepeatType = RepeatType.Weekly, + RepeatUnit = 1, + DaysOfWeek = "1" // Monday + }; + var completionDate = new DateTime(2025, 1, 8); // Wednesday + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + // Next Monday after Wednesday is Jan 13 + Assert.Equal(new DateTime(2025, 1, 13), next.DueDate); + } + + [Fact] + public void CreateNextRecurringTask_PreservesAllProperties() + { + var original = new TaskItem + { + Title = "Full Task", + Description = "A description", + RepeatType = RepeatType.Daily, + RepeatUnit = 2, + CustomRepeatDays = 5, + MovableRepeatDays = 3, + DaysOfWeek = "1,3", + ProjectId = "project-123" + }; + var completionDate = new DateTime(2025, 1, 1); + + var next = TaskService.CreateNextRecurringTask(original, completionDate); + + Assert.Equal(original.Title, next.Title); + Assert.Equal(original.Description, next.Description); + Assert.Equal(original.RepeatType, next.RepeatType); + Assert.Equal(original.RepeatUnit, next.RepeatUnit); + Assert.Equal(original.CustomRepeatDays, next.CustomRepeatDays); + Assert.Equal(original.MovableRepeatDays, next.MovableRepeatDays); + Assert.Equal(original.DaysOfWeek, next.DaysOfWeek); + Assert.Equal(original.ProjectId, next.ProjectId); + Assert.NotEqual(original.Id, next.Id); + } +} diff --git a/SarahsDailyApp.Tests/Services/UserStatsServiceTests.cs b/SarahsDailyApp.Tests/Services/UserStatsServiceTests.cs new file mode 100644 index 0000000..e947c94 --- /dev/null +++ b/SarahsDailyApp.Tests/Services/UserStatsServiceTests.cs @@ -0,0 +1,248 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class UserStatsServiceTests +{ + private static (UserStatsService service, TaskService taskService, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var taskService = new TaskService(db); + var service = new UserStatsService(db, taskService); + return (service, taskService, db); + } + + [Fact] + public async Task GetAsync_ReturnsDefaultStats() + { + var (service, _, _) = CreateService(); + + var stats = await service.GetAsync(); + + Assert.NotNull(stats); + Assert.Equal(1, stats.Id); + Assert.Equal(1, stats.Level); + Assert.Equal(0, stats.DailyStreak); + } + + [Fact] + public async Task GetSettingsAsync_ReturnsDefaultSettings() + { + var (service, _, _) = CreateService(); + + var settings = await service.GetSettingsAsync(); + + Assert.NotNull(settings); + Assert.Equal(1, settings.Id); + Assert.Equal(30, settings.TasksPerLevel); + } + + [Fact] + public async Task SaveSettingsAsync_UpdatesAndRecalculates() + { + var (service, taskService, _) = CreateService(); + + // Complete 10 tasks + for (int i = 0; i < 10; i++) + { + await taskService.SaveAsync(new TaskItem + { + Title = $"Task {i}", + Completed = true + }); + } + + var settings = await service.GetSettingsAsync(); + settings.TasksPerLevel = 5; + await service.SaveSettingsAsync(settings); + + var stats = await service.GetAsync(); + // 10 completed / 5 per level + 1 = 3 + Assert.Equal(3, stats.Level); + } + + [Fact] + public async Task RecalculateLevelAsync_CalculatesCorrectly() + { + var (service, taskService, _) = CreateService(); + + // Complete 31 tasks with default 30 per level + for (int i = 0; i < 31; i++) + { + await taskService.SaveAsync(new TaskItem + { + Title = $"Task {i}", + Completed = true + }); + } + + await service.RecalculateLevelAsync(); + + var stats = await service.GetAsync(); + // 31 / 30 + 1 = 2 + Assert.Equal(2, stats.Level); + } + + [Fact] + public async Task RecalculateLevelAsync_Level1_WhenNoCompletedTasks() + { + var (service, _, _) = CreateService(); + + await service.RecalculateLevelAsync(); + + var stats = await service.GetAsync(); + // 0 / 30 + 1 = 1 + Assert.Equal(1, stats.Level); + } + + [Fact] + public async Task RecalculateLevelAsync_Level1_WithLessThanThreshold() + { + var (service, taskService, _) = CreateService(); + + for (int i = 0; i < 29; i++) + { + await taskService.SaveAsync(new TaskItem + { + Title = $"Task {i}", + Completed = true + }); + } + + await service.RecalculateLevelAsync(); + + var stats = await service.GetAsync(); + // 29 / 30 + 1 = 1 + Assert.Equal(1, stats.Level); + } + + [Fact] + public async Task RecalculateLevelAsync_Level2_AtExactThreshold() + { + var (service, taskService, _) = CreateService(); + + for (int i = 0; i < 30; i++) + { + await taskService.SaveAsync(new TaskItem + { + Title = $"Task {i}", + Completed = true + }); + } + + await service.RecalculateLevelAsync(); + + var stats = await service.GetAsync(); + // 30 / 30 + 1 = 2 + Assert.Equal(2, stats.Level); + } + + [Fact] + public async Task UpdateDailyStreakAsync_StartsAt1_OnFirstActivity() + { + var (service, _, _) = CreateService(); + + await service.UpdateDailyStreakAsync(); + + var stats = await service.GetAsync(); + Assert.Equal(1, stats.DailyStreak); + Assert.Equal(DateTime.Today, stats.LastActivityDate); + } + + [Fact] + public async Task UpdateDailyStreakAsync_DoesNotIncrement_WhenCalledSameDay() + { + var (service, _, db) = CreateService(); + + // Simulate already updated today + var conn = await db.GetDatabaseAsync(); + var stats = await conn.FindAsync(1); + stats!.DailyStreak = 5; + stats.LastActivityDate = DateTime.Today; + await conn.UpdateAsync(stats); + + await service.UpdateDailyStreakAsync(); + + var updated = await service.GetAsync(); + Assert.Equal(5, updated.DailyStreak); + } + + [Fact] + public async Task UpdateDailyStreakAsync_Increments_WhenCalledNextDay() + { + var (service, _, db) = CreateService(); + + // Simulate last activity was yesterday + var conn = await db.GetDatabaseAsync(); + var stats = await conn.FindAsync(1); + stats!.DailyStreak = 3; + stats.LastActivityDate = DateTime.Today.AddDays(-1); + await conn.UpdateAsync(stats); + + await service.UpdateDailyStreakAsync(); + + var updated = await service.GetAsync(); + Assert.Equal(4, updated.DailyStreak); + } + + [Fact] + public async Task UpdateDailyStreakAsync_Resets_WhenGapInDays() + { + var (service, _, db) = CreateService(); + + // Simulate last activity was 3 days ago + var conn = await db.GetDatabaseAsync(); + var stats = await conn.FindAsync(1); + stats!.DailyStreak = 10; + stats.LastActivityDate = DateTime.Today.AddDays(-3); + await conn.UpdateAsync(stats); + + await service.UpdateDailyStreakAsync(); + + var updated = await service.GetAsync(); + Assert.Equal(1, updated.DailyStreak); + } + + [Fact] + public async Task GetProgressToNextLevelAsync_ReturnsRemainder() + { + var (service, taskService, _) = CreateService(); + + // Complete 35 tasks with default 30 per level + for (int i = 0; i < 35; i++) + { + await taskService.SaveAsync(new TaskItem + { + Title = $"Task {i}", + Completed = true + }); + } + + var progress = await service.GetProgressToNextLevelAsync(); + + // 35 % 30 = 5 + Assert.Equal(5, progress); + } + + [Fact] + public async Task GetProgressToNextLevelAsync_ReturnsZero_AtExactLevel() + { + var (service, taskService, _) = CreateService(); + + for (int i = 0; i < 60; i++) + { + await taskService.SaveAsync(new TaskItem + { + Title = $"Task {i}", + Completed = true + }); + } + + var progress = await service.GetProgressToNextLevelAsync(); + + // 60 % 30 = 0 + Assert.Equal(0, progress); + } +} diff --git a/SarahsDailyApp.Tests/Services/WishListServiceTests.cs b/SarahsDailyApp.Tests/Services/WishListServiceTests.cs new file mode 100644 index 0000000..2e6593f --- /dev/null +++ b/SarahsDailyApp.Tests/Services/WishListServiceTests.cs @@ -0,0 +1,208 @@ +using SarahsDailyApp.Models; +using SarahsDailyApp.Services; +using SarahsDailyApp.Tests.Helpers; + +namespace SarahsDailyApp.Tests.Services; + +public class WishListServiceTests +{ + private static (WishListService service, TestDatabaseService db) CreateService() + { + var db = new TestDatabaseService(); + var service = new WishListService(db); + return (service, db); + } + + // --- WishList tests --- + + [Fact] + public async Task GetAllListsAsync_ReturnsEmpty_WhenNoLists() + { + var (service, _) = CreateService(); + var lists = await service.GetAllListsAsync(); + Assert.Empty(lists); + } + + [Fact] + public async Task SaveListAsync_InsertsNewList_WithAutoOrder() + { + var (service, _) = CreateService(); + var list = new WishList { Name = "Birthday" }; + + await service.SaveListAsync(list); + + var all = await service.GetAllListsAsync(); + Assert.Single(all); + Assert.Equal("Birthday", all[0].Name); + Assert.Equal(0, all[0].Order); + } + + [Fact] + public async Task SaveListAsync_IncrementalOrder_ForMultipleLists() + { + var (service, _) = CreateService(); + await service.SaveListAsync(new WishList { Name = "List 1" }); + await service.SaveListAsync(new WishList { Name = "List 2" }); + await service.SaveListAsync(new WishList { Name = "List 3" }); + + var all = await service.GetAllListsAsync(); + Assert.Equal(3, all.Count); + Assert.Equal(0, all[0].Order); + Assert.Equal(1, all[1].Order); + Assert.Equal(2, all[2].Order); + } + + [Fact] + public async Task SaveListAsync_UpdatesExistingList() + { + var (service, _) = CreateService(); + var list = new WishList { Name = "Original" }; + await service.SaveListAsync(list); + + list.Name = "Updated"; + await service.SaveListAsync(list); + + var all = await service.GetAllListsAsync(); + Assert.Single(all); + Assert.Equal("Updated", all[0].Name); + } + + [Fact] + public async Task DeleteListAsync_RemovesList() + { + var (service, _) = CreateService(); + var list = new WishList { Name = "Delete Me" }; + await service.SaveListAsync(list); + + await service.DeleteListAsync(list.Id); + + var all = await service.GetAllListsAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task DeleteListAsync_UnlinksItems() + { + var (service, _) = CreateService(); + var list = new WishList { Name = "My List" }; + await service.SaveListAsync(list); + + var item = new WishItem { Title = "Linked Item", ListId = list.Id }; + await service.SaveItemAsync(item); + + await service.DeleteListAsync(list.Id); + + var items = await service.GetAllItemsAsync(); + Assert.Single(items); + Assert.Null(items[0].ListId); + } + + // --- WishItem tests --- + + [Fact] + public async Task GetAllItemsAsync_ReturnsEmpty_WhenNoItems() + { + var (service, _) = CreateService(); + var items = await service.GetAllItemsAsync(); + Assert.Empty(items); + } + + [Fact] + public async Task SaveItemAsync_InsertsNewItem_WithAutoOrder() + { + var (service, _) = CreateService(); + var item = new WishItem { Title = "Gadget" }; + + await service.SaveItemAsync(item); + + var all = await service.GetAllItemsAsync(); + Assert.Single(all); + Assert.Equal("Gadget", all[0].Title); + Assert.Equal(0, all[0].Order); + } + + [Fact] + public async Task SaveItemAsync_UpdatesExistingItem() + { + var (service, _) = CreateService(); + var item = new WishItem { Title = "Original" }; + await service.SaveItemAsync(item); + + item.Title = "Updated"; + await service.SaveItemAsync(item); + + var all = await service.GetAllItemsAsync(); + Assert.Single(all); + Assert.Equal("Updated", all[0].Title); + } + + [Fact] + public async Task DeleteItemAsync_RemovesItem() + { + var (service, _) = CreateService(); + var item = new WishItem { Title = "Delete Me" }; + await service.SaveItemAsync(item); + + await service.DeleteItemAsync(item.Id); + + var all = await service.GetAllItemsAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task GetItemsByListIdAsync_ReturnsMatchingItems() + { + var (service, _) = CreateService(); + var list = new WishList { Name = "My List" }; + await service.SaveListAsync(list); + + await service.SaveItemAsync(new WishItem { Title = "Item A", ListId = list.Id }); + await service.SaveItemAsync(new WishItem { Title = "Item B", ListId = list.Id }); + await service.SaveItemAsync(new WishItem { Title = "Item C", ListId = "other" }); + + var items = await service.GetItemsByListIdAsync(list.Id); + + Assert.Equal(2, items.Count); + Assert.All(items, i => Assert.Equal(list.Id, i.ListId)); + } + + [Fact] + public async Task GetItemsByListIdAsync_Null_ReturnsUnlinkedItems() + { + var (service, _) = CreateService(); + await service.SaveItemAsync(new WishItem { Title = "Unlinked", ListId = null }); + await service.SaveItemAsync(new WishItem { Title = "Linked", ListId = "some-list" }); + + var items = await service.GetItemsByListIdAsync(null); + + Assert.Single(items); + Assert.Equal("Unlinked", items[0].Title); + } + + [Fact] + public async Task ToggleItemCompletionAsync_TogglesState() + { + var (service, _) = CreateService(); + var item = new WishItem { Title = "Toggle", Completed = false }; + await service.SaveItemAsync(item); + + await service.ToggleItemCompletionAsync(item); + + var all = await service.GetAllItemsAsync(); + Assert.True(all[0].Completed); + } + + [Fact] + public async Task ToggleItemCompletionAsync_TogglesBack() + { + var (service, _) = CreateService(); + var item = new WishItem { Title = "Toggle Back", Completed = false }; + await service.SaveItemAsync(item); + + await service.ToggleItemCompletionAsync(item); + await service.ToggleItemCompletionAsync(item); + + var all = await service.GetAllItemsAsync(); + Assert.False(all[0].Completed); + } +} diff --git a/SarahsDailyApp.slnx b/SarahsDailyApp.slnx index 3080fe6..6d28b2a 100644 --- a/SarahsDailyApp.slnx +++ b/SarahsDailyApp.slnx @@ -1,3 +1,7 @@ - + + + + + diff --git a/SarahsDailyApp/App.xaml b/SarahsDailyApp/App.xaml index 329ef40..6b19106 100644 --- a/SarahsDailyApp/App.xaml +++ b/SarahsDailyApp/App.xaml @@ -2,6 +2,7 @@ @@ -9,6 +10,19 @@ + + + + + + + + + + + + + diff --git a/SarahsDailyApp/AppShell.xaml b/SarahsDailyApp/AppShell.xaml index 8790d92..4651971 100644 --- a/SarahsDailyApp/AppShell.xaml +++ b/SarahsDailyApp/AppShell.xaml @@ -3,12 +3,38 @@ x:Class="SarahsDailyApp.AppShell" xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" - xmlns:local="clr-namespace:SarahsDailyApp" - Title="SarahsDailyApp"> + xmlns:views="clr-namespace:SarahsDailyApp.Views" + FlyoutBehavior="Disabled" + Title="📋 Task Manager"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SarahsDailyApp/AppShell.xaml.cs b/SarahsDailyApp/AppShell.xaml.cs index 0616858..80b0549 100644 --- a/SarahsDailyApp/AppShell.xaml.cs +++ b/SarahsDailyApp/AppShell.xaml.cs @@ -1,10 +1,21 @@ -namespace SarahsDailyApp +using SarahsDailyApp.Views; + +namespace SarahsDailyApp { public partial class AppShell : Shell { public AppShell() { InitializeComponent(); + + // Register detail page routes for navigation + Routing.RegisterRoute(nameof(TaskDetailPage), typeof(TaskDetailPage)); + Routing.RegisterRoute(nameof(ProjectDetailPage), typeof(ProjectDetailPage)); + Routing.RegisterRoute(nameof(HabitDetailPage), typeof(HabitDetailPage)); + Routing.RegisterRoute(nameof(FinanceDetailPage), typeof(FinanceDetailPage)); + Routing.RegisterRoute(nameof(WishItemDetailPage), typeof(WishItemDetailPage)); + Routing.RegisterRoute(nameof(ShoppingItemDetailPage), typeof(ShoppingItemDetailPage)); + Routing.RegisterRoute(nameof(NoteDetailPage), typeof(NoteDetailPage)); } } } diff --git a/SarahsDailyApp/Converters/BoolToStrikethroughConverter.cs b/SarahsDailyApp/Converters/BoolToStrikethroughConverter.cs new file mode 100644 index 0000000..95360ec --- /dev/null +++ b/SarahsDailyApp/Converters/BoolToStrikethroughConverter.cs @@ -0,0 +1,16 @@ +using System.Globalization; + +namespace SarahsDailyApp.Converters; + +public class BoolToStrikethroughConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool completed && completed) + return TextDecorations.Strikethrough; + return TextDecorations.None; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/SarahsDailyApp/Converters/InverseBoolConverter.cs b/SarahsDailyApp/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..3fb52a8 --- /dev/null +++ b/SarahsDailyApp/Converters/InverseBoolConverter.cs @@ -0,0 +1,12 @@ +using System.Globalization; + +namespace SarahsDailyApp.Converters; + +public class InverseBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b ? !b : value; +} diff --git a/SarahsDailyApp/Converters/ValueConverters.cs b/SarahsDailyApp/Converters/ValueConverters.cs new file mode 100644 index 0000000..8b3933b --- /dev/null +++ b/SarahsDailyApp/Converters/ValueConverters.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using SarahsDailyApp.Models; + +namespace SarahsDailyApp.Converters; + +/// Converts a percentage (0–100) to a ProgressBar progress value (0.0–1.0). +public class PercentToProgressConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is double pct ? pct / 100.0 : 0.0; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Returns true when the value is not null (and not empty string). +public class IsNotNullConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is not null && (value is not string s || !string.IsNullOrEmpty(s)); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Returns true when an int value is not zero. +public class IsNotZeroConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is int i && i != 0; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Returns true when a RepeatType is not None. +public class IsNotNoneConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is RepeatType rt && rt != RepeatType.None; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Maps a completed bool to a status color. +public class StatusColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool completed && completed + ? Color.FromArgb("#4CAF50") + : Color.FromArgb("#FF9800"); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Maps a completed bool to a status text. +public class StatusTextConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool completed && completed ? "completed" : "pending"; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Maps a bool (IsComplete) to a color for completion status text. +public class CompletionColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool complete && complete + ? Color.FromArgb("#4CAF50") + : Color.FromArgb("#FF9800"); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Maps a bool (CanComplete) to an action button color. +public class ActionButtonColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool can && can + ? Color.FromArgb("#4CAF50") + : Color.FromArgb("#9E9E9E"); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Converts IsEditing bool to "Edit {Parameter}" or "Add {Parameter}" title. +public class EditTitleConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var entity = parameter as string ?? "Item"; + return value is bool editing && editing ? $"Edit {entity}" : $"Add {entity}"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/SarahsDailyApp/MainPage.xaml b/SarahsDailyApp/MainPage.xaml deleted file mode 100644 index e8fa7d6..0000000 --- a/SarahsDailyApp/MainPage.xaml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - -