using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.Tests.ViewModels; public class NotesEditorViewModelTests { private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today); private sealed class FakeNotes : INotesApi { public readonly List Store = new(); private int _seq; public Task> ListAsync(DateOnly day) => Task.FromResult(Store.Where(n => n.Date == Iso(day)).ToList()); public Task> ListBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(Store .Where(n => DateOnly.Parse(n.Date) >= start && DateOnly.Parse(n.Date) <= end) .ToList()); public Task AddAsync(DateOnly day, string text) { var dto = new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, false); Store.Add(dto); return Task.FromResult(dto); } public Task UpdateAsync(string id, string text) { var i = Store.FindIndex(n => n.Id == id); if (i >= 0) Store[i] = Store[i] with { Text = text }; return Task.CompletedTask; } public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; } public void Seed(DateOnly day, string text, bool fromAgent = false) => Store.Add(new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, fromAgent)); private static string Iso(DateOnly d) => d.ToString("yyyy-MM-dd"); } private sealed class ThrowingNotes : INotesApi { public string ExceptionMessage { get; init; } = "worker offline"; public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); public Task> ListBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List()); public Task AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage); public Task UpdateAsync(string id, string text) => Task.CompletedTask; public Task DeleteAsync(string id) => Task.CompletedTask; } private static async Task<(NotesEditorViewModel vm, FakeNotes api)> LoadedAsync(Action? seed = null) { var api = new FakeNotes(); seed?.Invoke(api); var vm = new NotesEditorViewModel(api); await vm.LoadAsync(); return (vm, api); } private static List Bullets(NotesEditorViewModel vm) => vm.Rows.OfType().ToList(); [Fact] public async Task LoadAsync_groups_notes_by_day_with_today_first() { var (vm, _) = await LoadedAsync(api => { api.Seed(Today.AddDays(-2), "vorgestern"); api.Seed(Today, "heute"); }); var headers = vm.Rows.OfType().ToList(); Assert.Equal(Today, headers[0].Day); Assert.True(headers[0].IsToday); Assert.Equal(Today.AddDays(-2), headers[1].Day); Assert.Equal("heute", ((NoteBulletViewModel)vm.Rows[1]).Text); } [Fact] public async Task LoadAsync_keeps_todays_header_when_there_is_nothing_yet() { var (vm, _) = await LoadedAsync(); Assert.Single(vm.Rows); Assert.True(vm.IsEmpty); } [Fact] public async Task AddBullet_files_the_note_under_today_and_clears_the_box() { var (vm, api) = await LoadedAsync(api => api.Seed(Today.AddDays(-1), "gestern")); vm.NewBulletText = "Standup vorbereitet"; await vm.AddBulletCommand.ExecuteAsync(null); Assert.Equal("Standup vorbereitet", ((NoteBulletViewModel)vm.Rows[1]).Text); Assert.Equal("", vm.NewBulletText); Assert.False(vm.IsEmpty); Assert.Equal(2, api.Store.Count); } [Fact] public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText() { var api = new ThrowingNotes(); var vm = new NotesEditorViewModel(api); await vm.LoadAsync(); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; vm.NewBulletText = "Standup vorbereitet"; await vm.AddBulletCommand.ExecuteAsync(null); Assert.Equal(api.ExceptionMessage, reportedError); Assert.Empty(Bullets(vm)); Assert.Equal("Standup vorbereitet", vm.NewBulletText); } [Fact] public async Task SplitBullet_saves_the_edit_then_opens_an_empty_row_below_and_focuses_it() { var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste")); NoteBulletViewModel? focused = null; vm.FocusRequested += b => focused = b; var first = Bullets(vm)[0]; first.Text = "erste, bearbeitet"; await vm.SplitBulletCommand.ExecuteAsync(first); var rows = Bullets(vm); Assert.Equal(2, rows.Count); Assert.Same(rows[1], focused); Assert.True(rows[1].IsNew); Assert.Equal("erste, bearbeitet", api.Store[0].Text); // The empty row is UI-only until it gets text, so Enter on a blank line leaves nothing behind. Assert.Single(api.Store); } [Fact] public async Task SplitBullet_on_an_emptied_note_deletes_it_and_adds_no_row() { var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit")); var only = Bullets(vm)[0]; only.Text = " "; await vm.SplitBulletCommand.ExecuteAsync(only); Assert.Empty(Bullets(vm)); Assert.Empty(api.Store); Assert.True(vm.IsEmpty); } [Fact] public async Task CommitBullet_persists_a_new_row_and_gives_it_an_id() { var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste")); await vm.SplitBulletCommand.ExecuteAsync(Bullets(vm)[0]); var fresh = Bullets(vm)[1]; fresh.Text = "zweite"; await vm.CommitBulletCommand.ExecuteAsync(fresh); Assert.False(fresh.IsNew); Assert.Equal(2, api.Store.Count); Assert.Equal("zweite", api.Store[1].Text); } [Fact] public async Task DeleteBullet_removes_it_and_focuses_the_note_above() { var (vm, api) = await LoadedAsync(api => { api.Seed(Today, "eins"); api.Seed(Today, "zwei"); }); NoteBulletViewModel? focused = null; vm.FocusRequested += b => focused = b; var second = Bullets(vm)[1]; await vm.DeleteBulletCommand.ExecuteAsync(second); Assert.Single(Bullets(vm)); Assert.Equal("eins", focused?.Text); Assert.Single(api.Store); } [Fact] public async Task DeleteBullet_on_the_first_note_of_the_day_does_not_reach_into_the_header() { var (vm, _) = await LoadedAsync(api => api.Seed(Today, "einzige")); var focusCalls = 0; vm.FocusRequested += _ => focusCalls++; await vm.DeleteBulletCommand.ExecuteAsync(Bullets(vm)[0]); Assert.Equal(0, focusCalls); Assert.True(vm.IsEmpty); } [Fact] public async Task CommitBullet_EmptyText_RemovesFromStoreAndList() { var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit")); var only = Bullets(vm)[0]; only.Text = " "; await vm.CommitBulletCommand.ExecuteAsync(only); Assert.Empty(Bullets(vm)); Assert.Empty(api.Store); } [Fact] public async Task CommitBullet_NonEmptyText_PersistsEdit() { var (vm, api) = await LoadedAsync(api => api.Seed(Today, "original")); var only = Bullets(vm)[0]; only.Text = "geaendert"; await vm.CommitBulletCommand.ExecuteAsync(only); Assert.Single(Bullets(vm)); Assert.Equal("geaendert", api.Store[0].Text); } [Fact] public async Task Agent_written_notes_are_marked_so_the_view_can_flag_them() { var (vm, _) = await LoadedAsync(api => { api.Seed(Today, "von mir"); api.Seed(Today, "von Claude", fromAgent: true); }); Assert.False(Bullets(vm)[0].FromAgent); Assert.True(Bullets(vm)[1].FromAgent); } }