+package m31
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "onehouse/protocol"
+)
+
+func TestNewRejectsInvalidAssemblySettings(t *testing.T) {
+ testCases := []struct {
+ name string
+ change func(*Settings)
+ }{
+ {name: "rejects noncanonical ID", change: func(s *Settings) {
+ s.DeviceID = "bad"
+ }},
+ {name: "requires host and port", change: func(s *Settings) {
+ s.Address = "example.com"
+ }},
+ {name: "requires a host", change: func(s *Settings) {
+ s.Address = ":502"
+ }},
+ {name: "requires numeric port", change: func(s *Settings) {
+ s.Address = "example.com:bad"
+ }},
+ {name: "rejects port zero", change: func(s *Settings) {
+ s.Address = "example.com:0"
+ }},
+ {name: "rejects excess port", change: func(s *Settings) {
+ s.Address = "example.com:65536"
+ }},
+ {name: "rejects broadcast unit", change: func(s *Settings) {
+ s.Unit = 0
+ }},
+ {name: "rejects reserved unit", change: func(s *Settings) {
+ s.Unit = 248
+ }},
+ {name: "rejects fast polling", change: func(s *Settings) {
+ s.PollInterval = time.Millisecond
+ }},
+ {name: "rejects slow polling", change: func(s *Settings) {
+ s.PollInterval = time.Minute + time.Nanosecond
+ }},
+ {name: "requires valid modules", change: func(s *Settings) {
+ s.Modules = nil
+ }},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ d, client, _ := testAssembly(t)
+ settings := Settings{
+ DeviceID: testDeviceID,
+ Address: "example.com:502",
+ Unit: 1,
+ PollInterval: 50 * time.Millisecond,
+ Modules: []Module{{Model: HostModel}},
+ }
+ tc.change(&settings)
+
+ _, err := New(settings, d.publisher, client)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestDriverReconnectsWithConfigurationAndFreshBaseline(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ d, client, transport := testAssembly(t)
+ client.connectErrors = []error{errors.New("unavailable")}
+ client.inputs[1] = true
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+
+ go func() {
+ done <- d.Run(ctx)
+ }()
+
+ synctest.Wait()
+ client.withLock(func(client *fakeClient) {
+ require.Len(t, client.attempts, 1)
+ require.Empty(t, client.registerWrites)
+ })
+ require.Empty(t, transport.ofKind(protocol.Event))
+
+ time.Sleep(time.Second)
+ synctest.Wait()
+ client.withLock(func(client *fakeClient) {
+ require.Len(t, client.attempts, 2)
+ require.Len(t, client.registerWrites, 33)
+ require.EqualValues(t, 200, client.registers[pulseWidthBase+2])
+ require.EqualValues(t, 1, client.registers[outputModeBase+2])
+ })
+ require.Empty(t, transport.ofKind(protocol.Event))
+
+ client.withLock(func(client *fakeClient) {
+ client.inputs[1] = false
+ })
+
+ time.Sleep(50 * time.Millisecond)
+ synctest.Wait()
+ require.Len(t, transport.ofKind(protocol.Event), 1)
+
+ client.withLock(func(client *fakeClient) {
+ client.inputErr = errors.New("connection lost")
+ })
+
+ time.Sleep(50 * time.Millisecond)
+ synctest.Wait()
+
+ availability, valid := transport.ofKind(protocol.Availability)[2].Payload.(protocol.AvailabilityPayload)
+ require.True(t, valid)
+ require.Equal(t, "offline", availability.Status)
+
+ client.withLock(func(client *fakeClient) {
+ client.inputErr = nil
+ client.inputs[1] = true
+ })
+
+ time.Sleep(time.Second)
+ synctest.Wait()
+ client.withLock(func(client *fakeClient) {
+ require.Len(t, client.attempts, 3)
+ require.Len(t, client.registerWrites, 66)
+ })
+ require.Len(t, transport.ofKind(protocol.Event), 1)
+ cancel()
+ require.ErrorIs(t, <-done, context.Canceled)
+ client.withLock(func(client *fakeClient) {
+ require.Equal(t, 3, client.closes)
+ })
+ })
+}
+
+func TestDriverBoundsReconnectBackoff(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ d, client, _ := testAssembly(t)
+ for range 8 {
+ client.withLock(func(client *fakeClient) {
+ client.connectErrors = append(client.connectErrors, errors.New("offline"))
+ })
+ }
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+
+ go func() {
+ done <- d.Run(ctx)
+ }()
+
+ synctest.Wait()
+
+ for _, delay := range []time.Duration{1, 2, 4, 8, 16, 30, 30} {
+ time.Sleep(delay * time.Second)
+ synctest.Wait()
+ }
+
+ client.withLock(func(client *fakeClient) {
+ require.Len(t, client.attempts, 8)
+ require.Equal(t, 30*time.Second, client.attempts[7].Sub(client.attempts[6]))
+ })
+ cancel()
+ require.ErrorIs(t, <-done, context.Canceled)
+ })
+}
+
+func TestDriverReturnsPublicationAndCleanupFailures(t *testing.T) {
+ testCases := []struct {
+ name string
+ kind protocol.Kind
+ closeFailure bool
+ }{
+ {name: "returns descriptor failure", kind: protocol.Descriptor},
+ {name: "returns online failure", kind: protocol.Availability},
+ {name: "returns baseline failure", kind: protocol.Reported},
+ {name: "returns cleanup failure", closeFailure: true},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ d, client, transport := testAssembly(t)
+ failure := errors.New("independent failure")
+ if tc.closeFailure {
+ client.closeErr = failure
+ client.connectErrors = []error{errors.New("device unavailable")}
+ } else {
+ transport.failKind, transport.err = tc.kind, failure
+ }
+
+ require.ErrorIs(t, d.Run(t.Context()), failure)
+ require.LessOrEqual(t, len(client.attempts), 1)
+ })
+ }
+}
+
+func TestDriverRefreshDoesNotRepeatCommandsOrEvents(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ d, client, transport := testAssembly(t)
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 2)
+
+ go func() {
+ done <- d.Run(ctx)
+ }()
+ go func() {
+ done <- d.publisher.Run(ctx)
+ }()
+
+ synctest.Wait()
+
+ count := len(transport.all())
+
+ d.Refresh()
+ synctest.Wait()
+ require.Greater(t, len(transport.all()), count)
+ require.Empty(t, client.writes)
+ require.Empty(t, transport.ofKind(protocol.Event))
+ cancel()
+ require.ErrorIs(t, <-done, context.Canceled)
+ require.ErrorIs(t, <-done, context.Canceled)
+ })
+}