Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
2026-06-04 09:39:11 +02:00

151 lines
5.8 KiB
C#

using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Prime;
public class PrimeRunnerTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private sealed class FakeClock : IPrimeClock
{
public DateTimeOffset Now { get; set; } = new DateTimeOffset(2026, 6, 3, 8, 0, 0, TimeSpan.FromHours(2));
}
private sealed class FakeClaudeProcess : IClaudeProcess
{
private readonly TimeSpan _delay;
private readonly int _exitCode;
private readonly string[] _emitLines;
private readonly string? _result;
public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null)
{
_delay = delay;
_exitCode = exitCode;
_emitLines = emitLines ?? [];
_result = result;
}
public async Task<RunResult> RunAsync(
string arguments,
string prompt,
string workingDirectory,
Func<string, Task> onStdoutLine,
CancellationToken ct)
{
if (_delay > TimeSpan.Zero)
await Task.Delay(_delay, ct);
foreach (var line in _emitLines)
await onStdoutLine(line);
return new RunResult { ExitCode = _exitCode, ResultMarkdown = _result ?? (_exitCode == 0 ? "ok" : null) };
}
}
private sealed class RecordingPrimeBroadcaster : IPrimeBroadcaster
{
public int StartedCount { get; private set; }
public List<string> Lines { get; } = [];
public List<bool> FinishedResults { get; } = [];
public Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => Task.CompletedTask;
public Task PrepStartedAsync() { StartedCount++; return Task.CompletedTask; }
public Task PrepLineAsync(string line) { Lines.Add(line); return Task.CompletedTask; }
public Task PrepFinishedAsync(bool success) { FinishedResults.Add(success); return Task.CompletedTask; }
}
private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) =>
NewRunner(new FakeClaudeProcess(claudeDelay, exitCode), new RecordingPrimeBroadcaster());
private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster) =>
new PrimeRunner(
claude,
_db.CreateFactory(),
new FakeClock(),
NullLogger<PrimeRunner>.Instance,
broadcaster);
private static PrimeScheduleDto DefaultSchedule() =>
new(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
[Fact]
public async Task FireAsync_returns_success_when_claude_exits_zero()
{
var runner = NewRunner();
var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal("Daily prep complete", outcome.Message);
}
[Fact]
public async Task FireAsync_returns_failure_when_claude_exits_nonzero()
{
var runner = NewRunner(exitCode: 1);
var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None);
Assert.False(outcome.Success);
Assert.Contains("exit code", outcome.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task FireAsync_returns_already_running_when_gate_held()
{
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
var schedule = DefaultSchedule();
var first = runner.FireAsync(schedule, CancellationToken.None);
var second = await runner.FireAsync(schedule, CancellationToken.None);
Assert.False(second.Success);
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
await first;
}
[Fact]
public async Task FireAsync_streams_started_lines_and_finished()
{
var broadcaster = new RecordingPrimeBroadcaster();
var claude = new FakeClaudeProcess(emitLines: ["{\"a\":1}", "{\"b\":2}"], exitCode: 0, result: "ok");
var runner = NewRunner(claude, broadcaster);
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var outcome = await runner.FireAsync(schedule, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, broadcaster.StartedCount);
Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines);
Assert.Single(broadcaster.FinishedResults);
Assert.True(broadcaster.FinishedResults[0]);
}
[Fact]
public async Task FireAsync_writes_last_run_to_prep_log_file()
{
var path = DailyPrepPrompt.LogPath();
if (File.Exists(path)) File.Delete(path);
var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
var contents = await File.ReadAllTextAsync(path);
Assert.Contains("lineA", contents);
Assert.Contains("lineB", contents);
// Truncation: a second run with different lines replaces the file.
var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
var after = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("lineA", after);
Assert.Contains("lineC", after);
}
}