OrgAdministration (App), damit WebUI und Desktop dieselben Regeln anwenden: - Einheiten anlegen, umbenennen, Ziel setzen, loeschen (nur leer); Leitung setzen (nur ein Mitglied). - Agent umziehen: haelt fest, seit wann und woher (UnitSinceUtc, PreviousUnitId); eine Leitung, die geht, laesst ihre Einheit ohne Leitung. Einheit umziehen: nie unter sich selbst; die Mitglieder bleiben in ihrer Einheit, ihre Position aendert sich, und das wird ihnen gesagt. - Kein Umzug unter einem laufenden Agenten. Das Konzept sagt "bei gestoppter Instanz", damit niemand mitten im Lauf andere Rechte bekommt. Die Verwaltung laeuft in der Instanz und kann sie nicht stoppen; sie lehnt ab, solange ein betroffener Agent laeuft. Das dient demselben Zweck, nur enger. - Rechte-Obergrenze: beim Schreiben geprueft (SetToolEnabled, SetToolSettings, und als Sicherheitsnetz Save() fuer den Desktop, der Werkzeuge direkt umschaltet). Obergrenze senken und in eine Obergrenze umziehen zeigen in der Vorschau, welche Werkzeuge wegfallen, und brauchen dafuer eine ausdrueckliche Bestaetigung. - Sammelwerte (Tagesbudget, Tokens, Modell, whenExhausted) mit Vorschau fuer die Einheit samt Untereinheiten: ein Wert je Agent oder ein verteilter Gesamtbetrag im Verhaeltnis der bisherigen Werte. Ausreisser (Faktor 3 zum Median der anderen) sind markiert und standardmaessig nicht gewaehlt. Geschrieben wird nur, was gewaehlt ist; die Einheit merkt sich den Wert nur als Vorgabe fuer neue Mitglieder. - Ein neuer Agent, der direkt in eine Einheit gelegt wird, bekommt deren Vorgaben; ein umgezogener behaelt seine Werte. - Jede Strukturaenderung steht mit Begruendung im Audit (Quelle "admin", Werkzeug "Organisation"). Der Organisationsabschnitt nennt der Leitung jetzt auch, wer die Einheit kuerzlich verlassen hat und wohin: Die abgebende Leitung wird unterrichtet, nicht nur die aufnehmende. Tests: OrgAdministrationTests (16). 1.333 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
9.7 KiB
C#
253 lines
9.7 KiB
C#
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Core.Config;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.App.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// #683: Units in the administration — create, lead, move, limit, write unit-wide values.
|
|
/// </summary>
|
|
public sealed class OrgAdministrationTests : IDisposable
|
|
{
|
|
private readonly TestHost _test;
|
|
|
|
public OrgAdministrationTests()
|
|
{
|
|
_test = new TestHost(
|
|
Agent("marketing-lead", "WebFetch", "AgentComm", "Taskboard"),
|
|
Agent("ads", "WebFetch", "Taskboard"),
|
|
Agent("listing", "WebFetch", "Taskboard"),
|
|
Agent("specialist", "WebFetch", "Taskboard", "Database"),
|
|
Agent("bookkeeper", "Database", "Taskboard"));
|
|
}
|
|
|
|
public void Dispose() => _test.Dispose();
|
|
|
|
private static AgentConfig Agent(string id, params string[] tools)
|
|
=> new() { AgentId = id, DisplayName = id, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
|
|
|
|
private OrgAdministration Org => _test.Host.Organisation;
|
|
|
|
private AgentConfig Find(string id) => _test.Host.AgentAdmin.Find(id)!;
|
|
|
|
private void BuildMarketing()
|
|
{
|
|
Org.CreateUnit("marketing", "Marketing", null, "Sell more.").Ok.ShouldBeTrue();
|
|
Org.CreateUnit("amazon", "Amazon", "marketing").Ok.ShouldBeTrue();
|
|
Org.MoveAgent("marketing-lead", "marketing", "setup").Ok.ShouldBeTrue();
|
|
Org.SetLeader("marketing", "marketing-lead").Ok.ShouldBeTrue();
|
|
foreach (var id in new[] { "ads", "listing", "specialist" })
|
|
Org.MoveAgent(id, "amazon", "setup").Ok.ShouldBeTrue();
|
|
}
|
|
|
|
// ═══════════════════ units ═══════════════════
|
|
|
|
[Fact]
|
|
public void A_unit_needs_a_valid_id_a_name_and_an_existing_parent()
|
|
{
|
|
Org.CreateUnit("bad id", "X", null).Ok.ShouldBeFalse();
|
|
Org.CreateUnit("x", " ", null).Ok.ShouldBeFalse();
|
|
Org.CreateUnit("x", "X", "nowhere").Ok.ShouldBeFalse();
|
|
Org.CreateUnit("x", "X", null).Ok.ShouldBeTrue();
|
|
Org.CreateUnit("x", "Again", null).Ok.ShouldBeFalse("ids are unique");
|
|
}
|
|
|
|
[Fact]
|
|
public void Only_a_member_can_lead_a_unit()
|
|
{
|
|
Org.CreateUnit("marketing", "Marketing", null);
|
|
|
|
Org.SetLeader("marketing", "ads").Ok.ShouldBeFalse();
|
|
Org.MoveAgent("ads", "marketing", "join");
|
|
Org.SetLeader("marketing", "ads").Ok.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Only_an_empty_unit_can_be_deleted()
|
|
{
|
|
BuildMarketing();
|
|
|
|
Org.DeleteUnit("amazon").Ok.ShouldBeFalse("it has members");
|
|
foreach (var id in new[] { "ads", "listing", "specialist" })
|
|
Org.MoveAgent(id, null, "leave");
|
|
Org.DeleteUnit("amazon").Ok.ShouldBeTrue();
|
|
}
|
|
|
|
// ═══════════════════ moves ═══════════════════
|
|
|
|
[Fact]
|
|
public void A_move_records_since_when_and_where_from()
|
|
{
|
|
BuildMarketing();
|
|
|
|
Org.MoveAgent("ads", "marketing", "promoted").Ok.ShouldBeTrue();
|
|
|
|
var ads = Find("ads");
|
|
ads.UnitId.ShouldBe("marketing");
|
|
ads.PreviousUnitId.ShouldBe("amazon");
|
|
ads.UnitSinceUtc.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_lead_that_moves_leaves_its_unit_without_lead()
|
|
{
|
|
BuildMarketing();
|
|
|
|
Org.MoveAgent("marketing-lead", "amazon", "reorg").Ok.ShouldBeTrue();
|
|
|
|
Org.Current().LeaderOf("marketing").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_unit_cannot_be_moved_under_itself()
|
|
{
|
|
BuildMarketing();
|
|
|
|
Org.MoveUnit("marketing", "amazon", "oops").Ok.ShouldBeFalse();
|
|
Org.MoveUnit("marketing", "marketing", "oops").Ok.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_moved_unit_takes_its_members_along_and_tells_them()
|
|
{
|
|
BuildMarketing();
|
|
Org.CreateUnit("marketplaces", "Marketplaces", "marketing");
|
|
var before = Find("ads").UnitSinceUtc;
|
|
|
|
Org.MoveUnit("amazon", "marketplaces", "a new level in between").Ok.ShouldBeTrue();
|
|
|
|
Find("ads").UnitId.ShouldBe("amazon", "the unit is the same");
|
|
Org.Current().PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
|
|
Find("ads").UnitSinceUtc.ShouldNotBe(before, "the position changed, so the briefing says so");
|
|
}
|
|
|
|
// ═══════════════════ tool ceiling ═══════════════════
|
|
|
|
[Fact]
|
|
public void A_move_under_a_lower_ceiling_is_refused_until_confirmed()
|
|
{
|
|
BuildMarketing();
|
|
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeFalse(
|
|
"the specialist has Database — that has to be confirmed via the preview first");
|
|
|
|
var preview = Org.PreviewToolCeiling("amazon", ["WebFetch", "Taskboard"]);
|
|
preview.ToolsLost.Keys.ShouldBe(["specialist"]);
|
|
preview.ToolsLost["specialist"].ShouldBe(["Database"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Lowering_a_ceiling_removes_the_tools_beyond_it_from_the_members()
|
|
{
|
|
BuildMarketing();
|
|
Org.MoveAgent("specialist", null, "out first").Ok.ShouldBeTrue();
|
|
|
|
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeTrue();
|
|
|
|
// And from now on it cannot be granted.
|
|
var refused = _test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true);
|
|
refused.Ok.ShouldBeFalse();
|
|
refused.Message.ShouldContain("ceiling");
|
|
}
|
|
|
|
[Fact]
|
|
public void Moving_into_a_ceiling_needs_the_tools_beyond_it_dropped_explicitly()
|
|
{
|
|
Org.CreateUnit("amazon", "Amazon", null);
|
|
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "setup").Ok.ShouldBeTrue();
|
|
|
|
var refused = Org.MoveAgent("specialist", "amazon", "join");
|
|
refused.Ok.ShouldBeFalse();
|
|
refused.Message.ShouldContain("Database");
|
|
Find("specialist").UnitId.ShouldBeNull("nothing happened");
|
|
|
|
Org.MoveAgent("specialist", "amazon", "join", dropExcessTools: true).Ok.ShouldBeTrue();
|
|
Find("specialist").Tools.Keys.ShouldNotContain("Database");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_ceiling_higher_up_limits_the_units_below()
|
|
{
|
|
BuildMarketing();
|
|
Org.MoveAgent("specialist", null, "out first");
|
|
Org.SetToolCeiling("marketing", ["WebFetch", "Taskboard", "AgentComm"], "setup").Ok.ShouldBeTrue();
|
|
|
|
_test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true).Ok.ShouldBeFalse(
|
|
"amazon has no ceiling of its own, but marketing above it has");
|
|
}
|
|
|
|
// ═══════════════════ unit-wide values ═══════════════════
|
|
|
|
[Fact]
|
|
public void A_unit_wide_budget_marks_the_specialist_as_an_outlier_and_leaves_it_unselected()
|
|
{
|
|
BuildMarketing();
|
|
Find("ads").Budget.DailyCostUsd = 5;
|
|
Find("listing").Budget.DailyCostUsd = 4;
|
|
Find("specialist").Budget.DailyCostUsd = 40; // deliberately large
|
|
Find("marketing-lead").Budget.DailyCostUsd = 6;
|
|
|
|
var preview = Org.PreviewSetting("marketing", UnitSetting.DailyCostUsd, "10");
|
|
|
|
preview.Count.ShouldBe(4, "the unit and its sub-units");
|
|
var specialist = preview.Single(c => c.AgentId == "specialist");
|
|
specialist.Outlier.ShouldBeTrue();
|
|
specialist.SelectedByDefault.ShouldBeFalse();
|
|
preview.Where(c => c.AgentId != "specialist").ShouldAllBe(c => c.SelectedByDefault && c.Proposed == "10.00");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_distributed_total_keeps_the_proportions()
|
|
{
|
|
BuildMarketing();
|
|
Find("ads").Budget.DailyCostUsd = 10;
|
|
Find("listing").Budget.DailyCostUsd = 10;
|
|
Find("specialist").Budget.DailyCostUsd = 20;
|
|
Find("marketing-lead").Budget.DailyCostUsd = 0;
|
|
|
|
var preview = Org.PreviewSetting("amazon", UnitSetting.DailyCostUsd, "80", UnitSettingMode.Distribute);
|
|
|
|
preview.Single(c => c.AgentId == "specialist").Proposed.ShouldBe("40.00");
|
|
preview.Single(c => c.AgentId == "ads").Proposed.ShouldBe("20.00");
|
|
}
|
|
|
|
[Fact]
|
|
public void Applying_writes_only_the_chosen_agents_and_remembers_the_default()
|
|
{
|
|
BuildMarketing();
|
|
Find("specialist").Budget.DailyCostUsd = 40;
|
|
|
|
var result = Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
|
|
new Dictionary<string, string> { ["ads"] = "10", ["listing"] = "10" }, "budget round", defaultValue: "10");
|
|
|
|
result.Ok.ShouldBeTrue(result.Message);
|
|
Find("ads").Budget.DailyCostUsd.ShouldBe(10);
|
|
Find("specialist").Budget.DailyCostUsd.ShouldBe(40, "not chosen, not touched");
|
|
Org.Current().Unit("amazon")!.Defaults.DailyCostUsd.ShouldBe(10);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_agent_outside_the_unit_cannot_be_written_through_it()
|
|
{
|
|
BuildMarketing();
|
|
|
|
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
|
|
new Dictionary<string, string> { ["bookkeeper"] = "1" }, "sneaky").Ok.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_new_agent_placed_in_a_unit_starts_with_its_defaults_but_a_moved_one_keeps_its_values()
|
|
{
|
|
BuildMarketing();
|
|
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd, new Dictionary<string, string>(), "defaults", defaultValue: "7");
|
|
Find("bookkeeper").Budget.DailyCostUsd = 30;
|
|
|
|
Org.MoveAgent("bookkeeper", "amazon", "moved").Ok.ShouldBeTrue();
|
|
Find("bookkeeper").Budget.DailyCostUsd.ShouldBe(30, "a move does not overwrite tuned values");
|
|
|
|
var created = _test.Host.AgentAdmin.Add(new NewAgent("newbie", "Newbie", "test/model", UnitId: "amazon"));
|
|
created.Ok.ShouldBeTrue(created.Message);
|
|
Find("newbie").Budget.DailyCostUsd.ShouldBe(7);
|
|
Find("newbie").UnitId.ShouldBe("amazon");
|
|
}
|
|
}
|