The editor was reachable from one button that only appeared in My Day, which is how it got forgotten. It is now a permanent sidebar entry next to My Day and Planned, and it fills the tasks island rather than the details pane, because a note list is content. Inside, the date picker is gone: today sits at the top with the capture box, older days follow as dated groups. Enter in a note saves it and opens the next one, Shift+Enter breaks the line so notes can be several, Backspace on an empty note deletes it and steps back up. Deleting also has a visible hover button instead of only "clear the text and hope". Agent-written notes carry a marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
8.1 KiB
C#
241 lines
8.1 KiB
C#
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<DailyNoteDto> Store = new();
|
|
private int _seq;
|
|
|
|
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
|
Task.FromResult(Store.Where(n => n.Date == Iso(day)).ToList());
|
|
|
|
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) =>
|
|
Task.FromResult(Store
|
|
.Where(n => DateOnly.Parse(n.Date) >= start && DateOnly.Parse(n.Date) <= end)
|
|
.ToList());
|
|
|
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text)
|
|
{
|
|
var dto = new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, false);
|
|
Store.Add(dto);
|
|
return Task.FromResult<DailyNoteDto?>(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<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
|
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
|
|
public Task<DailyNoteDto?> 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<FakeNotes>? seed = null)
|
|
{
|
|
var api = new FakeNotes();
|
|
seed?.Invoke(api);
|
|
var vm = new NotesEditorViewModel(api);
|
|
await vm.LoadAsync();
|
|
return (vm, api);
|
|
}
|
|
|
|
private static List<NoteBulletViewModel> Bullets(NotesEditorViewModel vm) =>
|
|
vm.Rows.OfType<NoteBulletViewModel>().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<NoteDayHeaderViewModel>().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);
|
|
}
|
|
}
|