chore(claude-do): merge [A4] WeeklyReport, DailyPrep und Planning-Aktionen auf Opera
ClaudeDo-Task: 7c6f2f66-8cd7-4abb-8151-aed42ae739b5
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Text;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Ui.Helpers;
|
using ClaudeDo.Ui.Helpers;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
@@ -19,11 +20,23 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[ObservableProperty] private bool _isPrepRunning;
|
[ObservableProperty] private bool _isPrepRunning;
|
||||||
|
|
||||||
|
// Covers the gap between the click and the worker's PrepStartedEvent broadcast — IsPrepRunning
|
||||||
|
// stays false until that event arrives, so without this the button click looks like a no-op.
|
||||||
|
public OperationStatus PrepOperation { get; } = new();
|
||||||
|
|
||||||
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
||||||
|
|
||||||
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
||||||
|
|
||||||
partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
// The long-running broadcast-driven state (IsPrepRunning) and the click-to-event gap
|
||||||
|
// (PrepOperation.IsRunning) both need to block a second click.
|
||||||
|
public bool IsPlanDayEnabled => !IsPrepRunning && !PrepOperation.IsRunning;
|
||||||
|
|
||||||
|
partial void OnIsPrepRunningChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||||
|
OnPropertyChanged(nameof(IsPlanDayEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
public PrepPanelViewModel(IWorkerClient worker)
|
public PrepPanelViewModel(IWorkerClient worker)
|
||||||
{
|
{
|
||||||
@@ -37,6 +50,15 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
|
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
|
||||||
|
|
||||||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||||
|
|
||||||
|
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
|
||||||
|
// into it, so IsRunning changes are relayed by hand.
|
||||||
|
PrepOperation.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
|
||||||
|
PlanDayCommand.NotifyCanExecuteChanged();
|
||||||
|
OnPropertyChanged(nameof(IsPlanDayEnabled));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -46,9 +68,12 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
|
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanPlanDay() => IsPlanDayEnabled;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanPlanDay))]
|
||||||
private async System.Threading.Tasks.Task PlanDayAsync()
|
private async System.Threading.Tasks.Task PlanDayAsync()
|
||||||
{
|
{
|
||||||
|
using var op = PrepOperation.Begin(Loc.T("ops.reports.runningDailyPrep"));
|
||||||
try { await _worker.RunDailyPrepNowAsync(); }
|
try { await _worker.RunDailyPrepNowAsync(); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[ObservableProperty] private bool _isLetClaudeVisible;
|
[ObservableProperty] private bool _isLetClaudeVisible;
|
||||||
[ObservableProperty] private bool _isQuickClaudeVisible;
|
[ObservableProperty] private bool _isQuickClaudeVisible;
|
||||||
|
|
||||||
|
// Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered
|
||||||
|
// from a context menu that closes the instant a click lands, so there is no per-row surface
|
||||||
|
// left standing to anchor an indicator to. The island header is the nearest surface still
|
||||||
|
// visible after the menu closes.
|
||||||
|
public OperationStatus PlanningOperation { get; } = new();
|
||||||
|
|
||||||
public event EventHandler? LetClaudeHandleRequested;
|
public event EventHandler? LetClaudeHandleRequested;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -152,6 +158,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Loc.LanguageChanged += _langChangedHandler;
|
Loc.LanguageChanged += _langChangedHandler;
|
||||||
_reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
|
_reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
|
||||||
_reconcileTimer.Start();
|
_reconcileTimer.Start();
|
||||||
|
|
||||||
|
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
|
||||||
|
// into it, so IsRunning changes are relayed by hand.
|
||||||
|
PlanningOperation.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
|
||||||
|
QueuePlanningSubtasksCommand.NotifyCanExecuteChanged();
|
||||||
|
FinalizePlanningSessionCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -1337,7 +1352,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
|
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.FinalizeNow:
|
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||||
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
|
await FinalizePlanningSessionAsync(row);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.Discard:
|
case UnfinishedPlanningModalResult.Discard:
|
||||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||||
@@ -1393,18 +1408,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||||
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || _worker is null) return;
|
if (row is null || _worker is null) return;
|
||||||
|
using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks"));
|
||||||
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null) return;
|
||||||
|
using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing"));
|
||||||
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
|
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,32 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
public WeeklyReportModalViewModel(IWorkerClient worker) => _worker = worker;
|
public WeeklyReportModalViewModel(IWorkerClient worker)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
|
||||||
|
// into it, so IsRunning changes are relayed by hand.
|
||||||
|
GenerateOperation.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
|
||||||
|
GenerateCommand.NotifyCanExecuteChanged();
|
||||||
|
OnPropertyChanged(nameof(EmptyStateVisible));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public OperationStatus GenerateOperation { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(HasReport))]
|
[NotifyPropertyChangedFor(nameof(HasReport))]
|
||||||
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
|
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
|
||||||
private string? _reportMarkdown;
|
private string? _reportMarkdown;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(GenerateCommand))]
|
|
||||||
private bool _isBusy;
|
|
||||||
|
|
||||||
[ObservableProperty] private DateTime? _startDate;
|
[ObservableProperty] private DateTime? _startDate;
|
||||||
[ObservableProperty] private DateTime? _endDate;
|
[ObservableProperty] private DateTime? _endDate;
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
|
|
||||||
public bool HasReport => !string.IsNullOrWhiteSpace(ReportMarkdown);
|
public bool HasReport => !string.IsNullOrWhiteSpace(ReportMarkdown);
|
||||||
public bool EmptyStateVisible => !HasReport && !IsBusy;
|
public bool EmptyStateVisible => !HasReport && !GenerateOperation.IsRunning;
|
||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||||||
@@ -68,21 +76,19 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase
|
|||||||
catch (Exception ex) { StatusMessage = ex.Message; }
|
catch (Exception ex) { StatusMessage = ex.Message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanGenerate() => !IsBusy;
|
private bool CanGenerate() => !GenerateOperation.IsRunning;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanGenerate))]
|
[RelayCommand(CanExecute = nameof(CanGenerate))]
|
||||||
private async Task Generate()
|
private async Task Generate()
|
||||||
{
|
{
|
||||||
if (!RangeValid) { StatusMessage = Loc.T("vm.weeklyReport.invalidRange"); return; }
|
if (!RangeValid) { StatusMessage = Loc.T("vm.weeklyReport.invalidRange"); return; }
|
||||||
IsBusy = true;
|
using var op = GenerateOperation.Begin(Loc.T("ops.reports.generatingWeekly"));
|
||||||
StatusMessage = Loc.T("vm.weeklyReport.generating");
|
StatusMessage = "";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ReportMarkdown = await _worker.GenerateWeekReportAsync(
|
ReportMarkdown = await _worker.GenerateWeekReportAsync(
|
||||||
DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
|
DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
|
||||||
StatusMessage = "";
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = Loc.T("vm.weeklyReport.error", ex.Message); }
|
catch (Exception ex) { StatusMessage = Loc.T("vm.weeklyReport.error", ex.Message); }
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||||
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||||
x:DataType="vm:DetailsIslandViewModel"
|
x:DataType="vm:DetailsIslandViewModel"
|
||||||
@@ -154,10 +155,13 @@
|
|||||||
<Panel IsVisible="{Binding IsPrepMode}">
|
<Panel IsVisible="{Binding IsPrepMode}">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border DockPanel.Dock="Top" Padding="12,8">
|
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||||
<Button Classes="btn primary"
|
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
|
||||||
Command="{Binding Prep.PlanDayCommand}"
|
<Button Classes="btn primary"
|
||||||
IsEnabled="{Binding !Prep.IsPrepRunning}"
|
Command="{Binding Prep.PlanDayCommand}"
|
||||||
Content="{loc:Tr details.planDay}"/>
|
IsEnabled="{Binding Prep.IsPlanDayEnabled}"
|
||||||
|
Content="{loc:Tr details.planDay}"/>
|
||||||
|
<ctl:OperationIndicator DataContext="{Binding Prep.PrepOperation}"/>
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Panel>
|
<Panel>
|
||||||
<islands:SessionTerminalView
|
<islands:SessionTerminalView
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||||
xmlns:uiconverters="using:ClaudeDo.Ui.Converters"
|
xmlns:uiconverters="using:ClaudeDo.Ui.Converters"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView"
|
x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView"
|
||||||
x:DataType="vm:TasksIslandViewModel">
|
x:DataType="vm:TasksIslandViewModel">
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
Foreground="{DynamicResource TextMuteBrush}"
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
Text="{Binding Subtitle}"
|
Text="{Binding Subtitle}"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<ctl:OperationIndicator DataContext="{Binding PlanningOperation}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
IsVisible="{Binding EmptyStateVisible}"/>
|
IsVisible="{Binding EmptyStateVisible}"/>
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.weeklyReport.regenerate}" Command="{Binding GenerateCommand}"
|
<Button Classes="btn" Content="{loc:Tr modals.weeklyReport.regenerate}" Command="{Binding GenerateCommand}"
|
||||||
IsVisible="{Binding HasReport}"/>
|
IsVisible="{Binding HasReport}"/>
|
||||||
|
<ctl:OperationIndicator DataContext="{Binding GenerateOperation}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,8,0,0"
|
<TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,8,0,0"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class PrepPanelViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class Worker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public TaskCompletionSource<bool> Gate { get; } = new();
|
||||||
|
public bool Throw { get; set; }
|
||||||
|
|
||||||
|
public override Task<bool> RunDailyPrepNowAsync() =>
|
||||||
|
Throw ? Task.FromException<bool>(new InvalidOperationException("boom")) : Gate.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanDay_sets_PrepOperation_IsRunning_before_the_started_broadcast_arrives()
|
||||||
|
{
|
||||||
|
var worker = new Worker();
|
||||||
|
var vm = new PrepPanelViewModel(worker);
|
||||||
|
|
||||||
|
var plan = vm.PlanDayCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.True(vm.PrepOperation.IsRunning);
|
||||||
|
Assert.False(vm.IsPrepRunning); // the broadcast hasn't fired yet
|
||||||
|
|
||||||
|
worker.Gate.SetResult(true);
|
||||||
|
await plan;
|
||||||
|
|
||||||
|
Assert.False(vm.PrepOperation.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanDayCommand_CanExecute_is_locked_while_running()
|
||||||
|
{
|
||||||
|
var worker = new Worker();
|
||||||
|
var vm = new PrepPanelViewModel(worker);
|
||||||
|
|
||||||
|
var plan = vm.PlanDayCommand.ExecuteAsync(null);
|
||||||
|
Assert.False(vm.PlanDayCommand.CanExecute(null));
|
||||||
|
Assert.False(vm.IsPlanDayEnabled);
|
||||||
|
|
||||||
|
worker.Gate.SetResult(true);
|
||||||
|
await plan;
|
||||||
|
|
||||||
|
Assert.True(vm.PlanDayCommand.CanExecute(null));
|
||||||
|
Assert.True(vm.IsPlanDayEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanDay_ends_the_operation_when_the_worker_call_throws()
|
||||||
|
{
|
||||||
|
var worker = new Worker { Throw = true };
|
||||||
|
var vm = new PrepPanelViewModel(worker);
|
||||||
|
|
||||||
|
await vm.PlanDayCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(vm.PrepOperation.IsRunning);
|
||||||
|
Assert.True(vm.PlanDayCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
// PlanningOperation backs the shared feedback surface for the two planning actions triggered
|
||||||
|
// from the task-row context menu (which closes the instant a click lands, so there is nothing
|
||||||
|
// per-row left to anchor an indicator to) — see docs/superpowers/plans/2026-08-11-operation-feedback.md, A4.
|
||||||
|
public class TasksIslandPlanningOperationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public TasksIslandPlanningOperationTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_planning_op_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GatedWorkerClient : StubWorkerClient
|
||||||
|
{
|
||||||
|
public override bool IsConnected => true;
|
||||||
|
public TaskCompletionSource<object?> QueueGate { get; } = new();
|
||||||
|
public TaskCompletionSource<object?> FinalizeGate { get; } = new();
|
||||||
|
public bool ThrowOnQueue { get; set; }
|
||||||
|
public bool ThrowOnFinalize { get; set; }
|
||||||
|
|
||||||
|
public override async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnQueue) throw new InvalidOperationException("boom");
|
||||||
|
await QueueGate.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnFinalize) throw new InvalidOperationException("boom");
|
||||||
|
await FinalizeGate.Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TasksIslandViewModel BuildViewModel(GatedWorkerClient worker) =>
|
||||||
|
new(new TestDbFactory(NewContext), worker);
|
||||||
|
|
||||||
|
private static TaskRowViewModel Row(string id) => new() { Id = id, Status = TaskStatus.Idle };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueuePlanningSubtasks_sets_PlanningOperation_IsRunning_while_pending()
|
||||||
|
{
|
||||||
|
var worker = new GatedWorkerClient();
|
||||||
|
var vm = BuildViewModel(worker);
|
||||||
|
var row = Row("parent-1");
|
||||||
|
|
||||||
|
var run = vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||||
|
Assert.True(vm.PlanningOperation.IsRunning);
|
||||||
|
|
||||||
|
worker.QueueGate.SetResult(null);
|
||||||
|
await run;
|
||||||
|
|
||||||
|
Assert.False(vm.PlanningOperation.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningSession_sets_PlanningOperation_IsRunning_while_pending()
|
||||||
|
{
|
||||||
|
var worker = new GatedWorkerClient();
|
||||||
|
var vm = BuildViewModel(worker);
|
||||||
|
var row = Row("parent-2");
|
||||||
|
|
||||||
|
var run = vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
|
||||||
|
Assert.True(vm.PlanningOperation.IsRunning);
|
||||||
|
|
||||||
|
worker.FinalizeGate.SetResult(null);
|
||||||
|
await run;
|
||||||
|
|
||||||
|
Assert.False(vm.PlanningOperation.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanningCommands_CanExecute_is_locked_while_either_operation_runs()
|
||||||
|
{
|
||||||
|
var worker = new GatedWorkerClient();
|
||||||
|
var vm = BuildViewModel(worker);
|
||||||
|
var row = Row("parent-3");
|
||||||
|
|
||||||
|
var run = vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||||
|
Assert.False(vm.QueuePlanningSubtasksCommand.CanExecute(row));
|
||||||
|
Assert.False(vm.FinalizePlanningSessionCommand.CanExecute(row));
|
||||||
|
|
||||||
|
worker.QueueGate.SetResult(null);
|
||||||
|
await run;
|
||||||
|
|
||||||
|
Assert.True(vm.QueuePlanningSubtasksCommand.CanExecute(row));
|
||||||
|
Assert.True(vm.FinalizePlanningSessionCommand.CanExecute(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueuePlanningSubtasks_ends_the_operation_when_the_worker_call_throws()
|
||||||
|
{
|
||||||
|
var worker = new GatedWorkerClient { ThrowOnQueue = true };
|
||||||
|
var vm = BuildViewModel(worker);
|
||||||
|
var row = Row("parent-4");
|
||||||
|
|
||||||
|
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||||
|
|
||||||
|
Assert.False(vm.PlanningOperation.IsRunning);
|
||||||
|
Assert.True(vm.QueuePlanningSubtasksCommand.CanExecute(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class WeeklyReportModalViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class Worker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public TaskCompletionSource<string> Gate { get; } = new();
|
||||||
|
public bool Throw { get; set; }
|
||||||
|
|
||||||
|
public override Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end) =>
|
||||||
|
Throw ? Task.FromException<string>(new InvalidOperationException("boom")) : Gate.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WeeklyReportModalViewModel Build(Worker worker)
|
||||||
|
{
|
||||||
|
var vm = new WeeklyReportModalViewModel(worker)
|
||||||
|
{
|
||||||
|
StartDate = new DateTime(2026, 6, 1),
|
||||||
|
EndDate = new DateTime(2026, 6, 3),
|
||||||
|
};
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generate_sets_IsRunning_while_pending_and_clears_it_after()
|
||||||
|
{
|
||||||
|
var worker = new Worker();
|
||||||
|
var vm = Build(worker);
|
||||||
|
|
||||||
|
var generate = vm.GenerateCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.True(vm.GenerateOperation.IsRunning);
|
||||||
|
|
||||||
|
worker.Gate.SetResult("# report");
|
||||||
|
await generate;
|
||||||
|
|
||||||
|
Assert.False(vm.GenerateOperation.IsRunning);
|
||||||
|
Assert.Equal("# report", vm.ReportMarkdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateCommand_CanExecute_is_locked_while_running()
|
||||||
|
{
|
||||||
|
var worker = new Worker();
|
||||||
|
var vm = Build(worker);
|
||||||
|
|
||||||
|
var generate = vm.GenerateCommand.ExecuteAsync(null);
|
||||||
|
Assert.False(vm.GenerateCommand.CanExecute(null));
|
||||||
|
|
||||||
|
worker.Gate.SetResult("# report");
|
||||||
|
await generate;
|
||||||
|
|
||||||
|
Assert.True(vm.GenerateCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generate_ends_the_operation_when_the_worker_call_throws()
|
||||||
|
{
|
||||||
|
var worker = new Worker { Throw = true };
|
||||||
|
var vm = Build(worker);
|
||||||
|
|
||||||
|
await vm.GenerateCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(vm.GenerateOperation.IsRunning);
|
||||||
|
Assert.True(vm.GenerateCommand.CanExecute(null));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(vm.StatusMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user