Files
2026-09-13 10:23:03 +01:00

226 lines
8.8 KiB
Go

package stepentry
import (
"context"
"reflect"
"testing"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
func TestComputeDigestDeterministicAndPositionSensitive(t *testing.T) {
a := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterClearDecisions},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
b := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterClearDecisions},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
if ComputeDigest(a) != ComputeDigest(b) {
t.Fatalf("expected identical declarations to produce identical digests")
}
reordered := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
{Type: wfmodels.OnEnterClearDecisions},
}
if ComputeDigest(a) == ComputeDigest(reordered) {
t.Fatalf("expected reordered declaration to change the digest")
}
withConfigOnly := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterClearDecisions, Config: map[string]interface{}{"unused": "value"}},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
if ComputeDigest(a) != ComputeDigest(withConfigOnly) {
t.Fatalf("expected a config-only change to leave the digest unchanged (shape digest, not config digest)")
}
}
func TestIsMarkerBearing(t *testing.T) {
cases := map[wfmodels.OnEnterActionType]bool{
wfmodels.OnEnterClearDecisions: true,
wfmodels.OnEnterQueueRunForEachParticipant: true,
wfmodels.OnEnterQueueRun: false,
wfmodels.OnEnterRunCodeReview: false,
wfmodels.OnEnterEnablePlanMode: false,
wfmodels.OnEnterAutoStartAgent: false,
wfmodels.OnEnterResetAgentContext: false,
wfmodels.OnEnterSetSessionMode: false,
}
for kind, want := range cases {
if got := IsMarkerBearing(kind); got != want {
t.Errorf("IsMarkerBearing(%s) = %v, want %v", kind, got, want)
}
}
}
// TestOwnershipTableMatchesDesign asserts the ownership declaration's
// membership matches the system design's table exactly, both dispatcher and
// marker-bearing columns classified independently for every reachable kind.
func TestOwnershipTableMatchesDesign(t *testing.T) {
cases := map[wfmodels.OnEnterActionType]Ownership{
wfmodels.OnEnterClearDecisions: {Dispatcher: DispatcherLedger, MarkerBearing: true},
wfmodels.OnEnterQueueRunForEachParticipant: {Dispatcher: DispatcherLedger, MarkerBearing: true},
wfmodels.OnEnterQueueRun: {Dispatcher: DispatcherLedger, MarkerBearing: false},
wfmodels.OnEnterRunCodeReview: {Dispatcher: DispatcherLedger, MarkerBearing: false},
wfmodels.OnEnterEnsureParticipantSeat: {Dispatcher: DispatcherLedger, MarkerBearing: false},
wfmodels.OnEnterEnablePlanMode: {Dispatcher: DispatcherMarker, MarkerBearing: false},
wfmodels.OnEnterAutoStartAgent: {Dispatcher: DispatcherMarker, MarkerBearing: false},
wfmodels.OnEnterResetAgentContext: {Dispatcher: DispatcherMarker, MarkerBearing: false},
wfmodels.OnEnterSetSessionMode: {Dispatcher: DispatcherMarker, MarkerBearing: false},
wfmodels.OnEnterConfigureSession: {Dispatcher: DispatcherMarker, MarkerBearing: false},
}
for kind, want := range cases {
got, ok := Owner(string(kind))
if !ok {
t.Errorf("Owner(%s): expected a classification, found none", kind)
continue
}
if got != want {
t.Errorf("Owner(%s) = %+v, want %+v", kind, got, want)
}
if OwnedByLedger(string(kind)) != (want.Dispatcher == DispatcherLedger) {
t.Errorf("OwnedByLedger(%s) disagrees with Owner", kind)
}
if OwnedByMarker(string(kind)) != (want.Dispatcher == DispatcherMarker) {
t.Errorf("OwnedByMarker(%s) disagrees with Owner", kind)
}
if MarkerBearing(string(kind)) != want.MarkerBearing {
t.Errorf("MarkerBearing(%s) disagrees with Owner", kind)
}
}
if len(cases) != 10 {
t.Fatalf("expected exactly 10 classified kinds (the design's table), got %d", len(cases))
}
// Checking cases against ownershipTable one direction (above) does not
// catch an entry added to ownershipTable that this test's own literal
// was never updated to include — both maps could still have the same
// size by coincidence, or the production table could simply be larger.
// Comparing lengths directly closes that gap: this test has direct
// access to the private table (same package), so a classified kind this
// test doesn't know about fails loudly here instead of silently passing.
if len(ownershipTable) != len(cases) {
t.Fatalf("ownershipTable has %d classified kinds but this test only checks %d — a kind was added to the production table without updating this test", len(ownershipTable), len(cases))
}
}
// TestOwnershipTableUnclassifiedKind asserts an unknown kind is classified by
// neither accessor, rather than defaulting to either dispatcher.
func TestOwnershipTableUnclassifiedKind(t *testing.T) {
if _, ok := Owner("move_to_next"); ok {
t.Fatalf("expected move_to_next (a transition action, not an entry action) to be unclassified")
}
if OwnedByLedger("move_to_next") || OwnedByMarker("move_to_next") {
t.Fatalf("expected an unclassified kind to be owned by neither dispatcher")
}
if MarkerBearing("move_to_next") {
t.Fatalf("expected an unclassified kind to be non-marker-bearing")
}
}
func TestBuildPendingAllocationNoEngineOwnedActions(t *testing.T) {
actions := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterEnablePlanMode},
{Type: wfmodels.OnEnterAutoStartAgent},
}
_, ok := BuildPendingAllocation("step-1", actions)
if ok {
t.Fatalf("expected no allocation for a step with no engine-owned on_enter actions")
}
}
func TestBuildPendingAllocationTracksDeclaredPositions(t *testing.T) {
actions := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterEnablePlanMode},
{Type: wfmodels.OnEnterClearDecisions},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
pending, ok := BuildPendingAllocation("step-1", actions)
if !ok {
t.Fatalf("expected an allocation for a step declaring engine-owned actions")
}
if pending.StepID != "step-1" {
t.Fatalf("expected StepID to be carried through, got %q", pending.StepID)
}
if len(pending.Positions) != 2 {
t.Fatalf("expected 2 engine-owned positions, got %d: %+v", len(pending.Positions), pending.Positions)
}
if pending.Positions[0].Position != 1 || pending.Positions[0].Kind != string(wfmodels.OnEnterClearDecisions) {
t.Errorf("unexpected first position: %+v", pending.Positions[0])
}
if pending.Positions[1].Position != 2 || pending.Positions[1].Kind != string(wfmodels.OnEnterQueueRunForEachParticipant) {
t.Errorf("unexpected second position: %+v", pending.Positions[1])
}
if pending.Digest != ComputeDigest(actions) {
t.Errorf("expected pending.Digest to match ComputeDigest(actions)")
}
}
// TestSerializePositions covers AC-OFFICE-STEP-ENTRY-DISPATCH-002.1/.9: the
// encoding allocateStepEntryIfPending persists into
// workflow_step_entries.marker_positions must actually carry the allocated
// positions, in order — a version that always returned "" would otherwise
// pass every other test in this package (BuildPendingAllocation's own
// callers never re-parse it).
func TestSerializePositions(t *testing.T) {
cases := []struct {
name string
positions []EnginePosition
want string
}{
{name: "empty", positions: nil, want: ""},
{name: "single", positions: []EnginePosition{{Position: 0, Kind: "clear_decisions"}}, want: "0"},
{
name: "multi preserves order",
positions: []EnginePosition{
{Position: 0, Kind: "clear_decisions"},
{Position: 2, Kind: "queue_run_for_each_participant"},
},
want: "0,2",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := SerializePositions(tc.positions); got != tc.want {
t.Fatalf("SerializePositions(%+v) = %q, want %q", tc.positions, got, tc.want)
}
})
}
}
func TestPendingAllocationContextRoundTrip(t *testing.T) {
ctx := context.Background()
if _, ok := FromContext(ctx); ok {
t.Fatalf("expected no pending allocation on a bare context")
}
pending := PendingAllocation{StepID: "step-1", Digest: "abc"}
ctx = WithPendingAllocation(ctx, pending)
got, ok := FromContext(ctx)
if !ok {
t.Fatalf("expected pending allocation to round-trip through context")
}
if !reflect.DeepEqual(got, pending) {
t.Errorf("got %+v, want %+v", got, pending)
}
}
func TestResultHolderContextRoundTrip(t *testing.T) {
ctx := context.Background()
if _, ok := ResultHolderFromContext(ctx); ok {
t.Fatalf("expected no result holder on a bare context")
}
holder := &AllocationResult{}
ctx = WithResultHolder(ctx, holder)
got, ok := ResultHolderFromContext(ctx)
if !ok {
t.Fatalf("expected result holder to round-trip through context")
}
got.EntryID = 42
got.EntrySeq = 3
if holder.EntryID != 42 || holder.EntrySeq != 3 {
t.Fatalf("expected mutations through the round-tripped pointer to reach the original holder")
}
}