feat(usage): PowerUse-Zeitfenster + Handoff-Schwellwert als Settings

Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).

Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.

Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
This commit is contained in:
mika kuns
2026-09-02 19:09:47 +02:00
parent 20b3b4ce2b
commit 69898be5c9
21 changed files with 1596 additions and 14 deletions
@@ -95,4 +95,56 @@ public sealed class PowerUseTests : IDisposable
Assert.True(settings.PowerUseEnabled);
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
}
[Fact]
public async Task AppSettings_WindowDefaults_AreEmptyAnd95Percent()
{
var settings = await new AppSettingsRepository(_ctx).GetAsync();
Assert.Null(settings.PowerUseWindowStart);
Assert.Null(settings.PowerUseWindowEnd);
Assert.Equal(95, settings.PowerUseHandoffPct);
}
/// UpdateAsync copies the incoming entity field by field onto the singleton row — a field
/// missing from that block never persists, however correctly the hub filled the entity. That
/// was true for PowerUseEnabled, ListHandlerMaxHandoffs and ModelPresets until 2026-09-02, so
/// pin the whole PowerUse group through the real write path.
[Fact]
public async Task AppSettings_UpdateAsync_PersistsThePowerUseGroup()
{
var repo = new AppSettingsRepository(_ctx);
var current = await repo.GetAsync();
current.PowerUseEnabled = true;
current.PowerUseWindowStart = "22:00";
current.PowerUseWindowEnd = "07:00";
current.PowerUseHandoffPct = 88;
current.ListHandlerMaxHandoffs = 40;
await repo.UpdateAsync(current);
using var reload = Reload();
var settings = await new AppSettingsRepository(reload).GetAsync();
Assert.True(settings.PowerUseEnabled);
Assert.Equal("22:00", settings.PowerUseWindowStart);
Assert.Equal("07:00", settings.PowerUseWindowEnd);
Assert.Equal(88, settings.PowerUseHandoffPct);
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
}
[Fact]
public async Task AppSettings_UpdateAsync_NormalizesAndClamps()
{
var repo = new AppSettingsRepository(_ctx);
var current = await repo.GetAsync();
current.PowerUseWindowStart = "nonsense";
current.PowerUseWindowEnd = " 07:00 ";
current.PowerUseHandoffPct = 5;
await repo.UpdateAsync(current);
using var reload = Reload();
var settings = await new AppSettingsRepository(reload).GetAsync();
Assert.Null(settings.PowerUseWindowStart);
Assert.Equal("07:00", settings.PowerUseWindowEnd);
Assert.Equal(50, settings.PowerUseHandoffPct);
}
}
@@ -0,0 +1,54 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Tests;
/// The window decides whether a handler session runs unattended, so its edges matter: an
/// off-by-one at midnight would leave an overnight window either always or never open.
public sealed class PowerUseWindowTests
{
private static TimeOnly T(string hhmm) => TimeOnly.ParseExact(hhmm, "HH:mm");
[Theory]
// Plain window, half-open: start included, end excluded.
[InlineData("09:00", "08:00", "17:00", true)]
[InlineData("08:00", "08:00", "17:00", true)]
[InlineData("17:00", "08:00", "17:00", false)]
[InlineData("07:59", "08:00", "17:00", false)]
[InlineData("23:30", "08:00", "17:00", false)]
// Wrap over midnight.
[InlineData("23:30", "22:00", "07:00", true)]
[InlineData("00:30", "22:00", "07:00", true)]
[InlineData("12:00", "22:00", "07:00", false)]
[InlineData("07:00", "22:00", "07:00", false)]
public void Contains_BothBoundsSet(string now, string start, string end, bool expected)
=> Assert.Equal(expected, PowerUseWindow.Contains(T(now), T(start), T(end)));
/// Only one bound is not a window — it must behave exactly like no window, or a half-filled
/// pair of boxes would silently switch PowerUse off for most of the day.
[Fact]
public void Contains_OnlyOneBoundSet_IsAlwaysOpen()
{
Assert.True(PowerUseWindow.Contains(T("12:00"), T("22:00"), null));
Assert.True(PowerUseWindow.Contains(T("12:00"), null, T("07:00")));
Assert.True(PowerUseWindow.Contains(T("12:00"), null, null));
}
[Theory]
[InlineData("22:00", "22:00")]
[InlineData(" 07:30 ", "07:30")]
[InlineData("7:30", null)]
[InlineData("22:00:00", null)]
[InlineData("nonsense", null)]
[InlineData("", null)]
[InlineData(null, null)]
public void Normalize_KeepsOnlyExactHhMm(string? input, string? expected)
=> Assert.Equal(expected, PowerUseWindow.Normalize(input));
[Fact]
public void IsOpen_UsesTheTimeOfDayOfTheGivenInstant()
{
Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 23, 30, 0), "22:00", "07:00"));
Assert.False(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), "22:00", "07:00"));
Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), null, null));
}
}