The conflict in the old cmd/claude-licence-manager/tools_test.go resolves to both sides: main moved it to cmd/manager, and the join's count of binding.changed.<consumer> under its family goes into the moved TestResyncSaysEachOnce. The whole-module test's runtime refused binding.changed.<consumer> because the manifest names the family binding.changed: it now accepts a key that extends a named family by further parts, as A-134 says the event. Builds: design 47 G A-134 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
195 lines
7.9 KiB
Go
195 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
. "claude-licence-manager/internal/licences"
|
|
)
|
|
|
|
// read is a licence's latest reading, read at t0.
|
|
func read(session, weekly float64) *UsageReading {
|
|
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
|
|
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
|
|
}
|
|
|
|
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
|
|
func weekly(pct float64) *UsageReading {
|
|
u := read(0, pct)
|
|
u.SessionPct, u.SessionResetsAt = nil, ""
|
|
return u
|
|
}
|
|
|
|
func sub(name string, u *UsageReading) Licence {
|
|
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
|
|
}
|
|
|
|
// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
|
|
func TestTheRuleMovesEachTable(t *testing.T) {
|
|
mm := newMesh(t)
|
|
on := func(consumers ...string) []Binding {
|
|
var out []Binding
|
|
for _, c := range consumers {
|
|
out = append(out, Binding{Consumer: c, Licence: "a"})
|
|
}
|
|
return out
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
licences []Licence
|
|
bindings []Binding
|
|
to string
|
|
window string
|
|
noTarget bool
|
|
consumers int
|
|
}{
|
|
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
|
|
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
|
|
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
|
|
on("x", "y", "z", "w"), "c", "weekly", false, 4},
|
|
{"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
|
|
on("x"), "", "", false, 0},
|
|
{"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
|
|
on("x", "y"), "b", "weekly", false, 2},
|
|
{"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
|
|
on("x"), "b", "weekly", false, 1},
|
|
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
|
|
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
|
|
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
|
|
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
|
|
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
|
|
} {
|
|
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
|
|
to, window, n := "", "", 0
|
|
if r.Switch != nil {
|
|
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
|
|
}
|
|
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
|
|
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
|
|
func fourOnA(t *testing.T) *miniMesh {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
|
|
_ = mm.store.SaveLicence(ctx, l)
|
|
}
|
|
for _, c := range []string{"ace", "g14", "laptop", "server"} {
|
|
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
|
|
}
|
|
mm.events = nil
|
|
return mm
|
|
}
|
|
|
|
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
|
|
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
|
|
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
|
|
func TestOneSwitchedPerAct(t *testing.T) {
|
|
mm := fourOnA(t)
|
|
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
|
|
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
|
|
t.Fatalf("acts %+v %v", acts, err)
|
|
}
|
|
var switched []map[string]any
|
|
changed := 0
|
|
for _, e := range mm.events {
|
|
name, body, _ := strings.Cut(e, " ")
|
|
var f map[string]any
|
|
_ = json.Unmarshal([]byte(body), &f)
|
|
switch name {
|
|
case "licence.switched":
|
|
switched = append(switched, f)
|
|
case "binding.changed." + fmt.Sprint(f["consumer"]):
|
|
changed++
|
|
}
|
|
}
|
|
if len(switched) != 1 || changed != 4 {
|
|
t.Fatalf("said %v", mm.events)
|
|
}
|
|
s := switched[0]
|
|
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
|
|
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
|
|
t.Fatalf("licence.switched %v", s)
|
|
}
|
|
audit := mm.audit.under("switched")
|
|
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
|
|
t.Fatalf("audit %v", audit)
|
|
}
|
|
b := mm.bound("laptop")
|
|
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
|
|
t.Fatalf("laptop %+v", b)
|
|
}
|
|
}
|
|
|
|
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
|
|
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
|
|
mm := fourOnA(t)
|
|
var told []string
|
|
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
|
|
told = append(told, headline+" | "+strings.Join(lines, " "))
|
|
return nil
|
|
}
|
|
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
|
|
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
|
|
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
|
|
t.Fatalf("told %v", told)
|
|
}
|
|
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
|
|
if len(told) != 1 {
|
|
t.Fatalf("the switch verb told %v", told)
|
|
}
|
|
}
|
|
|
|
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
|
|
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
|
|
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
|
|
ctx := context.Background()
|
|
mm := fourOnA(t)
|
|
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
|
|
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
|
|
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
|
|
}
|
|
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
|
|
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
|
|
t.Fatalf("dry %+v, act %+v", dry, acts)
|
|
}
|
|
for name, change := range map[string]func(*Settings){
|
|
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
|
|
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
|
|
} {
|
|
mm := fourOnA(t)
|
|
change(&mm.m.Settings)
|
|
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
|
|
t.Errorf("%s moved %+v", name, acts)
|
|
}
|
|
}
|
|
mm = fourOnA(t)
|
|
mm.m.Settings.SwitchAuto = false
|
|
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
|
|
t.Fatalf("by hand with switch_auto off moved %+v", acts)
|
|
}
|
|
}
|
|
|
|
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
|
|
func TestAReadingAtTheThresholdApplies(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
|
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
|
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
|
|
t.Fatalf("laptop %+v", b)
|
|
}
|
|
}
|