package m31 import ( "context" "errors" "fmt" "slices" "sync" "testing" "time" "github.com/stretchr/testify/require" "onehouse/driver" "onehouse/protocol" ) const testDeviceID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" type fakeClient struct { mu sync.Mutex inputs []bool coils []bool analog []uint16 registers map[uint16]uint16 writes []coilWrite registerWrites []uint16 attempts []time.Time closes int coilReads int connectErrors []error inputErr error readErr error writeErr error registerErr error closeErr error wrongConfig bool shortRead bool mismatch int writeDelay time.Duration inputDelay time.Duration } func (c *fakeClient) Connect(ctx context.Context, _ string, _ byte) error { c.mu.Lock() defer c.mu.Unlock() c.attempts = append(c.attempts, time.Now()) if len(c.connectErrors) != 0 { err := c.connectErrors[0] c.connectErrors = c.connectErrors[1:] return err } return ctx.Err() } func (c *fakeClient) Close() error { c.mu.Lock() defer c.mu.Unlock() c.closes++ return c.closeErr } func (c *fakeClient) ReadInputs(ctx context.Context, _ uint16) ([]bool, error) { c.mu.Lock() defer c.mu.Unlock() time.Sleep(c.inputDelay) if c.inputErr != nil || ctx.Err() != nil { return nil, errors.Join(c.inputErr, ctx.Err()) } return slices.Clone(c.inputs), nil } func (c *fakeClient) ReadCoils(ctx context.Context, _ uint16) ([]bool, error) { c.mu.Lock() defer c.mu.Unlock() c.coilReads++ if c.readErr != nil || ctx.Err() != nil { return nil, errors.Join(c.readErr, ctx.Err()) } if c.shortRead { return nil, nil } return slices.Clone(c.coils), nil } func (c *fakeClient) ReadRegisters(ctx context.Context, address uint16, count uint16) ([]uint16, error) { c.mu.Lock() defer c.mu.Unlock() if c.registerErr != nil || ctx.Err() != nil { return nil, errors.Join(c.registerErr, ctx.Err()) } if address >= pulseWidthBase { value := c.registers[address] if c.wrongConfig { value++ } return []uint16{value}, nil } return slices.Clone(c.analog[:min(int(count), len(c.analog))]), nil } func (c *fakeClient) WriteCoil(ctx context.Context, address uint16, value bool) error { return c.write(ctx, address, []bool{value}, false) } func (c *fakeClient) WriteCoils(ctx context.Context, address uint16, values []bool) error { return c.write(ctx, address, values, true) } func (c *fakeClient) write(ctx context.Context, address uint16, values []bool, multiple bool) error { c.mu.Lock() defer c.mu.Unlock() time.Sleep(c.writeDelay) if c.writeErr != nil || ctx.Err() != nil { return errors.Join(c.writeErr, ctx.Err()) } c.writes = append(c.writes, coilWrite{ address: address, values: slices.Clone(values), multiple: multiple, }) for offset, value := range values { if int(address)+offset != c.mismatch { c.coils[int(address)+offset] = value } } return nil } func (c *fakeClient) WriteRegister(ctx context.Context, address uint16, value uint16) error { c.mu.Lock() defer c.mu.Unlock() if c.registerErr != nil || ctx.Err() != nil { return errors.Join(c.registerErr, ctx.Err()) } c.registerWrites = append(c.registerWrites, address) if address >= pulseWidthBase { c.registers[address] = value } else if int(address) != c.mismatch { c.analog[address] = value } return nil } func (c *fakeClient) withLock(fn func(*fakeClient)) { c.mu.Lock() defer c.mu.Unlock() fn(c) } type coilWrite struct { address uint16 values []bool multiple bool } type fakeTransport struct { mu sync.Mutex messages []protocol.Message[any] failKind protocol.Kind err error } func (f *fakeTransport) Publish(ctx context.Context, publication protocol.Publication) error { f.mu.Lock() defer f.mu.Unlock() message, err := protocol.Parse(publication.Topic, publication.Data, publication.Retain) if err != nil { return fmt.Errorf("parse fake publication: %w", err) } if message.Address.Kind == f.failKind && f.err != nil { return f.err } if ctx.Err() != nil { return ctx.Err() } f.messages = append(f.messages, message) return nil } func (f *fakeTransport) ofKind(kind protocol.Kind) []protocol.Message[any] { f.mu.Lock() defer f.mu.Unlock() var messages []protocol.Message[any] for _, message := range f.messages { if message.Address.Kind == kind { messages = append(messages, message) } } return messages } func (f *fakeTransport) all() []protocol.Message[any] { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.messages) } func testAssembly(t *testing.T) (*Driver, *fakeClient, *fakeTransport) { t.Helper() transport := &fakeTransport{} publisher := driver.NewPublisher(transport) client := &fakeClient{ inputs: make([]bool, 16), coils: make([]bool, 32), analog: make([]uint16, 8), registers: make(map[uint16]uint16), mismatch: -1, } settings := Settings{ DeviceID: testDeviceID, Address: "example.com:502", Unit: 1, PollInterval: 50 * time.Millisecond, Modules: []Module{ {Model: HostModel, PulseWidths: map[int]time.Duration{2: 200 * time.Millisecond}}, {Model: OutputModel}, {Model: AnalogModel}, {Model: MixedModel}, }, } d, err := New(settings, publisher, client) require.NoError(t, err) return d, client, transport } func testConnection(t *testing.T) (*connection, *fakeClient, *fakeTransport) { t.Helper() d, client, transport := testAssembly(t) connection := &connection{owner: d} require.NoError(t, connection.start(t.Context())) transport.messages = nil client.writes = nil client.coilReads = 0 return connection, client, transport }