package iot import ( "context" "errors" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // fakeProvider 是桩 Provider,用于验证 Manager.Register 与设备操作路由, // 而不触碰具体的 mockProvider。 type fakeProvider struct { name string device Device ctrl func(*Device) } func (f fakeProvider) Name() string { return f.name } func (f fakeProvider) List(context.Context) ([]Device, error) { return []Device{f.device}, nil } func (f fakeProvider) Control(_ context.Context, id, _ string, _ map[string]any) (*Device, error) { if id != f.device.ID { return nil, ErrDeviceNotFound } if f.ctrl != nil { f.ctrl(&f.device) } cp := f.device.Clone() return &cp, nil } func (f fakeProvider) Status(_ context.Context, id string) (*Device, error) { if id != f.device.ID { return nil, ErrDeviceNotFound } cp := f.device.Clone() return &cp, nil } // recvIoT 阻塞等待,直到收到一条 DeviceUpdate 或超过等待时限。 func recvIoT(t *testing.T, ch <-chan DeviceUpdate, d time.Duration) DeviceUpdate { t.Helper() select { case v := <-ch: return v case <-time.After(d): t.Fatalf("timed out waiting for a DeviceUpdate after %v", d) return DeviceUpdate{} } } func TestNew_ProviderSelection(t *testing.T) { t.Parallel() tests := []struct { name string provider string wantLen int wantErr error }{ {name: "mock default", provider: "", wantLen: 8}, {name: "mock explicit", provider: "mock", wantLen: 8}, {name: "none disabled", provider: "none", wantLen: 0}, {name: "unknown", provider: "bogus", wantErr: ErrUnknownProvider}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: tt.provider}, nil) if tt.wantErr != nil { if !errors.Is(err, tt.wantErr) { t.Fatalf("New error = %v, want %v", err, tt.wantErr) } return } if err != nil { t.Fatalf("New: %v", err) } devices, err := m.List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } if len(devices) != tt.wantLen { t.Fatalf("List returned %d devices, want %d", len(devices), tt.wantLen) } }) } } func TestList_MockCoversAllTypes(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "mock"}, nil) if err != nil { t.Fatalf("New: %v", err) } devices, err := m.List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } if len(devices) != 8 { t.Fatalf("List returned %d devices, want 8", len(devices)) } seenID := map[string]bool{} seenType := map[string]int{} var anyOnline, anyOffline bool for _, d := range devices { if seenID[d.ID] { t.Fatalf("duplicate device ID %q", d.ID) } seenID[d.ID] = true seenType[d.Type]++ if d.Online { anyOnline = true } else { anyOffline = true } } for _, want := range []string{"light", "ac", "curtain", "sensor", "lock"} { if seenType[want] == 0 { t.Fatalf("List has no device of type %q (types: %v)", want, seenType) } } if !anyOnline || !anyOffline { t.Fatalf("List should contain both online and offline devices (online=%v offline=%v)", anyOnline, anyOffline) } } func TestStatus_ExistingAndMissing(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "mock"}, nil) if err != nil { t.Fatalf("New: %v", err) } d, err := m.Status(context.Background(), "ac.bedroom") if err != nil { t.Fatalf("Status: %v", err) } if d.Online { t.Fatalf("ac.bedroom should be offline, got online=%v", d.Online) } if _, err := m.Status(context.Background(), "nope"); !errors.Is(err, ErrDeviceNotFound) { t.Fatalf("Status(missing) error = %v, want ErrDeviceNotFound", err) } } func TestControl_Actions(t *testing.T) { t.Parallel() tests := []struct { name string deviceID string action string params map[string]any wantKey string wantVal any }{ {name: "light on", deviceID: "light.living", action: "on", wantKey: "on", wantVal: true}, {name: "light off", deviceID: "light.living", action: "off", wantKey: "on", wantVal: false}, {name: "light toggle", deviceID: "light.living", action: "toggle", wantKey: "on", wantVal: true}, {name: "light brightness", deviceID: "light.living", action: "set_brightness", params: map[string]any{"brightness": 80}, wantKey: "brightness", wantVal: 80}, {name: "ac temperature", deviceID: "ac.living", action: "set_temperature", params: map[string]any{"temperature": 24}, wantKey: "temperature", wantVal: 24}, {name: "ac mode", deviceID: "ac.living", action: "set_mode", params: map[string]any{"mode": "heat"}, wantKey: "mode", wantVal: "heat"}, {name: "curtain open", deviceID: "curtain.living", action: "open", wantKey: "position", wantVal: 100}, {name: "curtain close", deviceID: "curtain.living", action: "close", wantKey: "position", wantVal: 0}, {name: "lock lock", deviceID: "lock.front", action: "lock", wantKey: "locked", wantVal: true}, {name: "lock unlock", deviceID: "lock.front", action: "unlock", wantKey: "locked", wantVal: false}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "mock"}, nil) if err != nil { t.Fatalf("New: %v", err) } d, err := m.Control(context.Background(), tt.deviceID, tt.action, tt.params) if err != nil { t.Fatalf("Control: %v", err) } if got := d.State[tt.wantKey]; got != tt.wantVal { t.Fatalf("state[%s] = %v, want %v", tt.wantKey, got, tt.wantVal) } }) } } func TestControl_CapabilityConstraints(t *testing.T) { t.Parallel() tests := []struct { name string deviceID string action string params map[string]any wantErr error }{ {name: "sensor read-only", deviceID: "sensor.env", action: "read", wantErr: ErrUnsupported}, {name: "sensor any action", deviceID: "sensor.env", action: "on", wantErr: ErrUnsupported}, {name: "unknown action", deviceID: "light.living", action: "explode", wantErr: ErrUnsupported}, {name: "missing device", deviceID: "nope", action: "on", wantErr: ErrDeviceNotFound}, {name: "offline device", deviceID: "ac.bedroom", action: "on", wantErr: ErrOffline}, {name: "non-numeric brightness", deviceID: "light.living", action: "set_brightness", params: map[string]any{"brightness": "x"}, wantErr: ErrInvalidParam}, {name: "missing brightness param", deviceID: "light.living", action: "set_brightness", wantErr: ErrInvalidParam}, {name: "ac invalid mode", deviceID: "ac.living", action: "set_mode", params: map[string]any{"mode": "freeze"}, wantErr: ErrUnsupported}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "mock"}, nil) if err != nil { t.Fatalf("New: %v", err) } if _, err := m.Control(context.Background(), tt.deviceID, tt.action, tt.params); !errors.Is(err, tt.wantErr) { t.Fatalf("Control error = %v, want %v", err, tt.wantErr) } }) } } func TestControl_PublishesDeviceUpdate(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "mock"}, nil) if err != nil { t.Fatalf("New: %v", err) } updates := make(chan DeviceUpdate, 4) unsub, err := m.Bus().Subscribe(context.Background(), DeviceUpdateSubject, func(_ context.Context, e bus.Event) error { updates <- e.Payload.(DeviceUpdate) return nil }) if err != nil { t.Fatalf("Subscribe: %v", err) } defer unsub() if _, err := m.Control(context.Background(), "light.living", "on", nil); err != nil { t.Fatalf("Control: %v", err) } u := recvIoT(t, updates, 2*time.Second) if u.Provider != "mock" { t.Fatalf("event provider = %q, want mock", u.Provider) } if u.Action != "on" { t.Fatalf("event action = %q, want on", u.Action) } if u.Device.ID != "light.living" { t.Fatalf("event device ID = %q, want light.living", u.Device.ID) } if got := u.Device.State["on"]; got != true { t.Fatalf("event state on = %v, want true", got) } } func TestRegister_ProviderSource(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "none"}, nil) if err != nil { t.Fatalf("New: %v", err) } dev := Device{ID: "fake.dev", Name: "fake", Type: "light", Online: true, State: map[string]any{"on": false}} if err := m.Register(fakeProvider{name: "fake", device: dev}); err != nil { t.Fatalf("Register: %v", err) } devices, err := m.List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } if len(devices) != 1 || devices[0].ID != "fake.dev" { t.Fatalf("List = %+v, want one fake.dev device", devices) } if err := m.Register(fakeProvider{name: "fake"}); !errors.Is(err, ErrDuplicate) { t.Fatalf("duplicate Register error = %v, want ErrDuplicate", err) } if err := m.Register(nil); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("nil Register error = %v, want ErrInvalidArgument", err) } } func TestNone_Disabled(t *testing.T) { t.Parallel() m, err := New(config.IoTConfig{Provider: "none"}, nil) if err != nil { t.Fatalf("New: %v", err) } devices, err := m.List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } if len(devices) != 0 { t.Fatalf("List returned %d devices, want 0", len(devices)) } if _, err := m.Control(context.Background(), "x", "on", nil); !errors.Is(err, ErrProviderNotRegistered) { t.Fatalf("Control error = %v, want ErrProviderNotRegistered", err) } }