- TeamOverview.RollUp: je Einheit die Zustaende aller Agenten darin und
darunter, offene Freigaben und der dringlichste Zustand (Urgency:
haengt > Problem > Freigabe > Budget > arbeitet > frei). Gezaehlt wird in
der Instanz, die Seite zeichnet nur.
- /api/team liefert Einheiten und je Agent Einheit und Leitung.
- team.js: Einheiten als Container, zugeklappt als Kasten mit
Zusammenfassung ("6 agents · ⚠ 1 · ✋ 1 · ▶ 2") in der Farbe des
dringlichsten Zustands. Kanten laufen zum sichtbaren Stellvertreter und
werden gebuendelt (×n); was in einer zugeklappten Einheit bleibt, wird
nicht gezeichnet. Bis 15 Agenten alles offen, darueber nur die oberste
Ebene; der Zustand je Instanz im localStorage.
- Seitenleiste fuer Einheiten: Pfad, Ziel, Mitglieder (Leitung zuerst),
Untereinheiten, Auf-/Zuklappen, Link zur Organisation.
- Layout: rectpacking trifft das Seitenverhaeltnis nur grob; es werden
bis zu vier Vorgaben probiert und die zur Flaeche passendste behalten.
- Sprachschluessel en/de.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
7.1 KiB
C#
166 lines
7.1 KiB
C#
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();
|
||
}
|