- 5 个文件:123 行英文注释 → 99 行中文注释(中文更紧凑,内容无删减)。 - 机械校验(git diff -U0 去掉 // 行与空行)输出为空;gofmt -l 干净(改动前亦干净)。 - 验收:go build/vet/test ./internal/iot/... 全绿。
298 lines
9.2 KiB
Go
298 lines
9.2 KiB
Go
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)
|
|
}
|
|
}
|