Files
RichardandClaude Opus 5.5 f649f2fd7a Organigramm: Lage je Abteilung in der Teamuebersicht (#688)
- TeamMember: Laeufe und Kosten heute, offene und blockierte Aufgaben je Agent
  (eine Abfrage fuer alle, nicht eine je Agent)
- TeamUnit: dieselben Werte samt Untereinheiten, offene Vorgaenge aus der Einheit,
  heutige Kostenmeldung (#699)
- team.js: Lagezeile auf jeder Karte und in der Seitenleiste, Instanz summiert;
  Spruenge zu Kosten je Einheit und zu den Vorgaengen; i18n en/de
- Tests: TeamOverviewTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:50:17 +02:00

249 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #711: The rules that decide what the team overview shows for an agent. Every state comes
/// from a recorded fact, never from a model's judgement.
/// </summary>
public sealed class TeamOverviewTests
{
private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
private static ActiveRun RunningFor(TimeSpan duration)
=> new("r", "worker", "task", null, null, Now - duration);
[Fact]
public void Nothing_running_and_a_good_last_run_is_idle()
=> TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
[Fact]
public void An_agent_without_any_run_yet_is_idle()
=> TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
[Fact]
public void A_run_in_progress_is_working()
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void A_run_far_beyond_the_usual_duration_is_stuck()
// Usually 2 minutes: three times that is 6, running for 7.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
.ShouldBe(TeamState.Stuck);
[Fact]
public void Short_runs_get_a_floor_before_they_count_as_stuck()
// Usually 20 seconds: three times is one minute, but the floor is five.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void Without_history_a_run_is_stuck_only_after_a_long_while()
{
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
}
[Fact]
public void An_open_approval_outranks_working()
// What the human can do something about comes first.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.WaitingForApproval);
[Fact]
public void Stuck_outranks_an_open_approval()
=> TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Stuck);
[Theory]
[InlineData("Failed")]
[InlineData("LoopLimitExceeded")]
[InlineData("TargetBusy")]
public void A_failed_last_run_is_a_problem(string status)
=> TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
[Fact]
public void A_refused_last_run_shows_the_exhausted_budget()
=> TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
[Fact]
public void A_new_run_hides_the_old_problem()
// A new run is the attempt to get past it; the problem shows again if it fails.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
// ═══════════════════ stage 1: units roll up ═══════════════════
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
private static OrgChart Shop() => new(
[
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new UnitConfig { Id = "accounting", Name = "Accounting" }
],
[
new AgentConfig { AgentId = "ceo" },
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
[Fact]
public void A_problem_deep_down_shows_on_every_container_above_it()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("ceo", null, TeamState.Working),
Member("marketing-lead", "marketing", TeamState.Working),
Member("amazon-lead", "amazon", TeamState.Idle),
Member("ads", "amazon", TeamState.Problem),
Member("bookkeeper", "accounting", TeamState.Idle)
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
marketing.Worst.ShouldBe(TeamState.Problem);
marketing.States[TeamState.Working].ShouldBe(1);
marketing.States[TeamState.Problem].ShouldBe(1);
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Open_approvals_are_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
]);
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
}
[Fact]
public void A_unit_knows_its_path_and_its_lead()
{
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
amazon.Path.ShouldBe("Marketing / Amazon");
amazon.LeaderAgentId.ShouldBe("amazon-lead");
amazon.Agents.ShouldBe(0);
amazon.Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Stuck_is_the_most_urgent_and_idle_the_least()
{
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
byUrgency.First().ShouldBe(TeamState.Stuck);
byUrgency.Last().ShouldBe(TeamState.Idle);
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
}
[Fact]
public void Without_units_there_is_nothing_to_roll_up()
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
.ShouldBeEmpty();
// ─── #720: conflict hotspots ───
[Fact]
public void A_hotspot_is_shown_relative_to_the_agents_folder()
{
var agents = Path.Combine(Path.GetTempPath(), "inst", "Agents");
var configured = new List<AgentConfig> { new() { AgentId = "a", SharedWorkspacePath = Path.Combine(agents, "SharedWorkspace") } };
TeamOverview.Readable(Path.Combine(agents, "SharedWorkspaces", "articles", "list.csv"), configured)
.ShouldBe("SharedWorkspaces/articles/list.csv");
TeamOverview.Readable(Path.Combine(agents, "SharedWorkspace", "plan.md"), configured)
.ShouldBe("SharedWorkspace/plan.md");
}
[Fact]
public void A_path_elsewhere_stays_as_it_is()
{
var configured = new List<AgentConfig> { new() { AgentId = "a", SharedWorkspacePath = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace") } };
var elsewhere = Path.Combine(Path.GetTempPath(), "other", "x.md");
TeamOverview.Readable(elsewhere, configured).ShouldBe(elsewhere);
TeamOverview.Readable(elsewhere, []).ShouldBe(elsewhere);
}
// ═══════════════════ the department's situation (#688) ═══════════════════
[Fact]
public void A_departments_day_is_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.Idle) with { RunsToday = 2, CostToday = 0.5m, OpenTasks = 1 },
Member("ads", "amazon", TeamState.Idle) with { RunsToday = 5, CostToday = 1.25m, OpenTasks = 3, BlockedTasks = 1 }
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.RunsToday.ShouldBe(7);
marketing.CostToday.ShouldBe(1.75m);
marketing.OpenTasks.ShouldBe(4);
marketing.BlockedTasks.ShouldBe(1);
units.Single(u => u.Id == "amazon").CostToday.ShouldBe(1.25m);
}
[Fact]
public async Task The_snapshot_carries_runs_cost_tasks_cases_and_the_cost_alert_of_today()
{
var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "team-" + Guid.NewGuid().ToString("N")[..8]);
try
{
var storage = new Core.Storage.SqliteStorage(Path.Combine(directory, "state.db"));
var usage = new Core.Accounting.SqliteUsageRepository(storage);
var board = new Core.Tasks.TaskboardService(new Core.Tasks.SqliteTaskRepository(storage), Path.Combine(directory, "tasks"));
var cases = new Core.Cases.SqliteCaseRepository(storage);
await usage.RecordAsync(new Core.Accounting.RunUsage { AgentId = "ads", Model = "m", CostUsd = 0.4m, CostIsKnown = true, Status = "Completed", OccurredAt = DateTime.Now }, default);
await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Bid for spring", Assignee = "@ads" }, default);
await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Waiting", Assignee = "@ads", Status = Core.Tasks.TaskItemStatus.Blocked }, default);
await cases.SaveAsync(new Core.Cases.CaseRecord { Id = "c-1", Subject = "Field", OpenedBy = "ads", OpenedUnitId = "amazon", OpenedAt = DateTime.UtcNow }, default);
var today = DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd");
var overview = new TeamOverview(null, null, null, usage, () => Shop().Agents.ToList(),
organisation: Shop, board: board, cases: cases,
costAlerts: _ => Task.FromResult<IReadOnlyList<Core.Accounting.RaisedCostAlert>>(
[new Core.Accounting.RaisedCostAlert(today, "amazon", Core.Accounting.CostAlertKind.Deviation, "x", DateTime.UtcNow)]));
var snapshot = await overview.SnapshotAsync(default);
var ads = snapshot.Members.Single(m => m.AgentId == "ads");
ads.RunsToday.ShouldBe(1);
ads.CostToday.ShouldBe(0.4m);
ads.OpenTasks.ShouldBe(1);
ads.BlockedTasks.ShouldBe(1);
var marketing = snapshot.Units!.Single(u => u.Id == "marketing");
marketing.OpenCases.ShouldBe(1, "a case raised in amazon lies inside marketing");
snapshot.Units!.Single(u => u.Id == "amazon").CostAlert.ShouldBe("Deviation");
marketing.CostAlert.ShouldBeNull("the alert was raised on amazon, not on marketing");
}
finally
{
Core.Storage.SqliteStorage.ReleasePooledConnections(directory);
try { Directory.Delete(directory, recursive: true); } catch { /* cleanup is secondary */ }
}
}
}