package m31 import ( "context" "errors" "fmt" "slices" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" "onehouse/driver" "onehouse/protocol" ) func TestCommandsWriteContiguousRangesAndReplayResults(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} var commands []driver.Command for address := 5; address < 18; address++ { commands = append(commands, testCommand(t, c.owner.topology.outputs[address].id, "true")) } slices.Reverse(commands) require.NoError(t, h.handle(t.Context(), commands)) require.Len(t, client.writes, 1) require.EqualValues(t, 5, client.writes[0].address) require.True(t, client.writes[0].multiple) require.Len(t, client.writes[0].values, 13) require.Equal(t, 1, client.coilReads) reports := transport.ofKind(protocol.Reported) results := transport.ofKind(protocol.Result) require.Len(t, reports, 13) require.Len(t, results, 13) for _, command := range commands { for _, report := range reports { if report.Address.Target == command.Capability { require.Equal(t, command.Metadata.MessageID, report.Metadata.CausationID) } } } require.NoError(t, h.handle(t.Context(), commands)) require.Len(t, client.writes, 1) require.Equal(t, 1, client.coilReads) replayed := transport.ofKind(protocol.Result)[13:] byCommand := make(map[string]protocol.Metadata) for _, result := range results { byCommand[result.Address.Target] = result.Metadata } for _, result := range replayed { require.Equal(t, byCommand[result.Address.Target], result.Metadata) } } func TestCommandsKeepGapsAndRejectNonsteadyMembers(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} commands := []driver.Command{ testCommand(t, "m0.do0", "true"), testCommand(t, "m0.do1", "true"), testCommand(t, "m0.do2", "true"), testCommand(t, "m0.do4", "true"), testCommand(t, "m2.ao0", "2"), testCommand(t, "m0.di0", "true"), } require.NoError(t, h.handle(t.Context(), commands)) require.Len(t, client.writes, 2) require.EqualValues(t, 0, client.writes[0].address) require.Len(t, client.writes[0].values, 2) require.EqualValues(t, 4, client.writes[1].address) require.False(t, client.writes[1].multiple) require.False(t, client.coils[2]) require.False(t, client.coils[3]) require.Len(t, transport.ofKind(protocol.Result), 6) } func TestCommandsFailAllOnTransportErrorAndOnlyMismatchOtherwise(t *testing.T) { testCases := []struct { name string change func(*fakeClient) transportFailure bool }{ {name: "fails all on write failure", transportFailure: true, change: func(c *fakeClient) { c.writeErr = errors.New("write failed") }}, {name: "fails all after a partial write", transportFailure: true, change: func(c *fakeClient) { c.writeErr = errors.New("later write failed") c.failAfterWrites = 1 }}, {name: "fails all on read failure", transportFailure: true, change: func(c *fakeClient) { c.readErr = errors.New("read failed") }}, {name: "fails all on short read", transportFailure: true, change: func(c *fakeClient) { c.shortRead = true }}, {name: "fails only mismatching member", change: func(c *fakeClient) { c.mismatch = 1 }}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { c, client, transport := testConnection(t) tc.change(client) h := &commandHandler{connection: c} commands := []driver.Command{testCommand(t, "m0.do0", "true"), testCommand(t, "m0.do1", "true"), testCommand(t, "m1.do7", "true")} err := h.handle(t.Context(), commands) if tc.transportFailure { var failure *deviceError require.ErrorAs(t, err, &failure) } else { require.NoError(t, err) } require.Len(t, transport.ofKind(protocol.Result), 3) for index, command := range commands { record, exists := c.owner.ledger.Lookup(command.Metadata.MessageID) require.True(t, exists) status := "completed" if tc.transportFailure || index == 1 { status = "failed" } require.Equal(t, status, record.Payload.Status) } }) } } func TestCommandsCompletePulseWithoutReadbackOrFeedback(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} command := testCommand(t, "m0.do2", "true") require.NoError(t, h.handle(t.Context(), []driver.Command{command})) require.Len(t, client.writes, 1) require.Zero(t, client.coilReads) require.Empty(t, transport.ofKind(protocol.Reported)) require.NoError(t, h.handle(t.Context(), []driver.Command{command})) require.Len(t, client.writes, 1) command = testCommand(t, "m0.do2", "false") require.NoError(t, h.handle(t.Context(), []driver.Command{command})) require.Len(t, client.writes, 1) record, exists := c.owner.ledger.Lookup(command.Metadata.MessageID) require.True(t, exists) require.Equal(t, "rejected", record.Payload.Status) } func TestCommandsWriteVoltageAndCheckFeedback(t *testing.T) { testCases := []struct { name string change func(*fakeClient) wantStatus string wantError bool }{ {name: "completes verified voltage", wantStatus: "completed", change: func(*fakeClient) { }}, {name: "fails mismatching voltage", wantStatus: "failed", change: func(c *fakeClient) { c.mismatch = 3 }}, {name: "fails register transport", wantStatus: "failed", wantError: true, change: func(c *fakeClient) { c.registerErr = errors.New("register failed") }}, {name: "fails analog read transport", wantStatus: "failed", wantError: true, change: func(c *fakeClient) { c.registerReadErr = errors.New("read failed") }}, {name: "fails unsafe feedback", wantStatus: "failed", wantError: true, change: func(c *fakeClient) { c.analog[0] = 10001 }}, {name: "fails incomplete feedback", wantStatus: "failed", wantError: true, change: func(c *fakeClient) { c.analog = c.analog[:4] }}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { c, client, transport := testConnection(t) tc.change(client) h := &commandHandler{connection: c} command := testCommand(t, "m2.ao3", "5.5") err := h.handle(t.Context(), []driver.Command{command}) if tc.wantError { require.Error(t, err) } else { require.NoError(t, err) } record, exists := c.owner.ledger.Lookup(command.Metadata.MessageID) require.True(t, exists) require.Equal(t, tc.wantStatus, record.Payload.Status) if tc.wantStatus == "completed" { require.EqualValues(t, 5500, client.analog[3]) require.Len(t, transport.messages, 2) require.Equal(t, protocol.Reported, transport.messages[0].Address.Kind) require.Equal(t, command.Metadata.MessageID, transport.messages[0].Metadata.CausationID) } }) } } func TestCommandsRememberBeforeFailedPublication(t *testing.T) { for _, kind := range []protocol.Kind{protocol.Reported, protocol.Result} { t.Run(fmt.Sprint("preserves first outcome after ", kind, " failure"), func(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} failure := errors.New("publication failed") transport.failKind, transport.err = kind, failure commands := []driver.Command{testCommand(t, "m0.do0", "true"), testCommand(t, "m0.do1", "true")} require.ErrorIs(t, h.handle(t.Context(), commands), failure) record, exists := c.owner.ledger.Lookup(commands[1].Metadata.MessageID) require.True(t, exists) require.Equal(t, "completed", record.Payload.Status) transport.err = nil require.NoError(t, h.handle(t.Context(), commands)) require.Len(t, client.writes, 1) }) } } func TestCommandsRecheckExpiryBeforeEachWriteAndHonorCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { c, client, _ := testConnection(t) h := &commandHandler{connection: c} first := testCommand(t, "m0.do0", "true") second := testCommand(t, "m0.do4", "true") expires := time.Now().Add(time.Millisecond) second.Metadata.ExpiresAt = &expires client.writeDelay = 2 * time.Millisecond require.NoError(t, h.handle(t.Context(), []driver.Command{first, second})) require.Len(t, client.writes, 1) record, exists := c.owner.ledger.Lookup(second.Metadata.MessageID) require.True(t, exists) require.Equal(t, "rejected", record.Payload.Status) ctx, cancel := context.WithCancel(t.Context()) cancel() third := testCommand(t, "m0.do1", "true") require.ErrorIs(t, h.handle(ctx, []driver.Command{third}), context.Canceled) require.Len(t, client.writes, 1) }) } func TestCommandsKeepWrittenResultsAfterExpiryPublicationFails(t *testing.T) { testCases := []struct { name string mismatch bool readFailure bool failOnce bool status string }{ { name: "keeps completed write", status: "completed", }, { name: "keeps mismatching write", mismatch: true, status: "failed", }, { name: "keeps unverified write", readFailure: true, status: "failed", }, { name: "preserves rejection failure after readback failure", readFailure: true, failOnce: true, status: "failed", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} first := testCommand(t, "m0.do0", "true") second := testCommand(t, "m0.do4", "true") expires := time.Now().Add(time.Millisecond) second.Metadata.ExpiresAt = &expires client.writeDelay = 2 * time.Millisecond sendErr := errors.New("rejection publication failed") transport.failOnce = tc.failOnce transport.failKind, transport.err = protocol.Result, sendErr if tc.mismatch { client.mismatch = 0 } if tc.readFailure { client.readErr = errors.New("readback failed") } commands := []driver.Command{first, second} err := h.handle(t.Context(), commands) require.ErrorIs(t, err, sendErr) var failure *deviceError require.NotErrorAs(t, err, &failure) require.Len(t, client.writes, 1) record, exists := c.owner.ledger.Lookup(first.Metadata.MessageID) require.True(t, exists) require.Equal(t, tc.status, record.Payload.Status) rejected, exists := c.owner.ledger.Lookup(second.Metadata.MessageID) require.True(t, exists) require.Equal(t, "rejected", rejected.Payload.Status) transport.err, client.readErr = nil, nil require.NoError(t, h.handle(t.Context(), commands)) require.Len(t, client.writes, 1) results := transport.ofKind(protocol.Result) require.Equal(t, record.Metadata, results[len(results)-2].Metadata) require.Equal(t, record.Payload, results[len(results)-2].Payload) require.Equal(t, rejected.Metadata, results[len(results)-1].Metadata) }) }) } } func TestCommandsCompleteUnchangedSingleWithoutState(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} command := testCommand(t, "m0.do0", "false") require.NoError(t, h.handle(t.Context(), []driver.Command{command})) require.Len(t, client.writes, 1) require.False(t, client.writes[0].multiple) require.Len(t, transport.messages, 1) require.Equal(t, protocol.Result, transport.messages[0].Address.Kind) } func TestCommandsReturnPublicationErrorsWithoutLosingOutcomes(t *testing.T) { testCases := []struct { name string capability string value string offline bool physicalFailure bool }{ {name: "retains completed pulse after result failure", capability: "m0.do2", value: "true"}, {name: "retains voltage after result failure", capability: "m2.ao0", value: "5"}, {name: "returns rejection failure", capability: "m0.di0", value: "true"}, {name: "returns offline result failure", capability: "m0.do0", value: "true", offline: true}, {name: "returns offline rejection failure", capability: "m0.di0", value: "true", offline: true}, {name: "joins pulse and result failure", capability: "m0.do2", value: "true", physicalFailure: true}, {name: "joins batch and result failure", capability: "m0.do0", value: "true", physicalFailure: true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { c, client, transport := testConnection(t) h := &commandHandler{connection: c} command := testCommand(t, tc.capability, tc.value) sendErr := errors.New("send failed") physicalErr := errors.New("physical failure") transport.failKind, transport.err = protocol.Result, sendErr if tc.physicalFailure { client.writeErr = physicalErr } var err error if tc.offline { err = h.unavailable(t.Context(), []driver.Command{command}) } else { err = h.handle(t.Context(), []driver.Command{command}) } require.ErrorIs(t, err, sendErr) if tc.physicalFailure { require.ErrorIs(t, err, physicalErr) var physical *deviceError require.NotErrorAs(t, err, &physical) } record, exists := c.owner.ledger.Lookup(command.Metadata.MessageID) require.True(t, exists) transport.err, client.writeErr = nil, nil count := len(client.writes) require.NoError(t, h.handle(t.Context(), []driver.Command{command})) require.Len(t, client.writes, count) replay := transport.ofKind(protocol.Result) require.Equal(t, record.Metadata, replay[len(replay)-1].Metadata) }) } }