package m31 import ( "context" "errors" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" "onehouse/driver" "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, _, router := 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, router, client) require.Error(t, err) }) } d, client, _, _ := testAssembly(t) router, err := driver.NewRouter(nil) require.NoError(t, err) _, err = New(Settings{ DeviceID: testDeviceID, Address: "example.com:502", Unit: 1, PollInterval: 50 * time.Millisecond, Modules: []Module{{Model: HostModel}}, }, d.publisher, router, client) require.ErrorContains(t, err, "command queue") } func TestDriverReconnectsWithConfigurationAndFreshBaseline(t *testing.T) { synctest.Test(t, func(t *testing.T) { d, client, transport, router := 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)) command := testCommand(t, "m0.do0", "true") queueCommands(t, router, []driver.Command{command}) synctest.Wait() record, exists := d.ledger.Lookup(command.Metadata.MessageID) require.True(t, exists) require.Equal(t, "failed", record.Payload.Status) client.withLock(func(client *fakeClient) { require.Empty(t, client.writes) }) 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) queueCommands(t, router, []driver.Command{command}) synctest.Wait() client.withLock(func(client *fakeClient) { require.Empty(t, client.writes) }) command = testCommand(t, "m1.do15", "true") queueCommands(t, router, []driver.Command{command}) synctest.Wait() client.withLock(func(client *fakeClient) { require.Len(t, client.writes, 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) }) }