feat(miniprogram): BLE 增强层 — DataBuffer + GenericBleAdapter + DataSyncScheduler
- DataBuffer: 离线持久化缓冲(分桶存储 + 去重 + 容量管理) - GenericBleAdapter: 基于 Bluetooth SIG 标准 Health Profile 的通用适配器 (Heart Rate 0x180D / Health Thermometer 0x1809 / Blood Pressure 0x1810) - DataSyncScheduler: 定时自动同步调度(基于时间间隔判断是否需要同步) - BLEManager: 集成 DataBuffer 替换简单 Storage 缓存 - device-sync 页面: 注册 CustomBandAdapter + 自动同步 + 状态显示 - 新增 vitest 单元测试配置,30 个测试全部通过
This commit is contained in:
69
apps/miniprogram/__tests__/services/ble/BLEManager.test.ts
Normal file
69
apps/miniprogram/__tests__/services/ble/BLEManager.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// 使用 vi.hoisted 确保 storage 在 mock 提升前可用
|
||||
const { storage } = vi.hoisted(() => ({
|
||||
storage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
openBluetoothAdapter: vi.fn().mockResolvedValue({}),
|
||||
closeBluetoothAdapter: vi.fn().mockResolvedValue({}),
|
||||
startBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
|
||||
stopBluetoothDevicesDiscovery: vi.fn().mockResolvedValue({}),
|
||||
onBluetoothDeviceFound: vi.fn(),
|
||||
offBluetoothDeviceFound: vi.fn(),
|
||||
createBLEConnection: vi.fn().mockResolvedValue({}),
|
||||
closeBLEConnection: vi.fn().mockResolvedValue({}),
|
||||
getBLEDeviceServices: vi.fn().mockResolvedValue({ services: [] }),
|
||||
getBLEDeviceCharacteristics: vi.fn().mockResolvedValue({ characteristics: [] }),
|
||||
notifyBLECharacteristicValueChange: vi.fn().mockResolvedValue({}),
|
||||
onBLECharacteristicValueChange: vi.fn(),
|
||||
onBLEConnectionStateChange: vi.fn(),
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
|
||||
describe('BLEManager DataBuffer 集成', () => {
|
||||
let manager: BLEManager;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
manager = new BLEManager();
|
||||
manager.registerAdapter(XiaomiBandAdapter);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.destroy();
|
||||
});
|
||||
|
||||
it('registerAdapter 添加适配器', () => {
|
||||
const count = (manager as any).adapters.length;
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('getCachedReadings 返回空数组(未连接时)', () => {
|
||||
const readings = manager.getCachedReadings();
|
||||
expect(readings).toEqual([]);
|
||||
});
|
||||
|
||||
it('flushPendingReadings 无缓存时返回 0', async () => {
|
||||
const uploadFn = vi.fn().mockResolvedValue(0);
|
||||
const count = await manager.flushPendingReadings(uploadFn);
|
||||
expect(count).toBe(0);
|
||||
expect(uploadFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DataBuffer 实例已初始化', () => {
|
||||
const buffer = (manager as any).dataBuffer;
|
||||
expect(buffer).toBeDefined();
|
||||
expect(typeof buffer.push).toBe('function');
|
||||
expect(typeof buffer.flush).toBe('function');
|
||||
expect(typeof buffer.restore).toBe('function');
|
||||
});
|
||||
});
|
||||
89
apps/miniprogram/__tests__/services/ble/DataBuffer.test.ts
Normal file
89
apps/miniprogram/__tests__/services/ble/DataBuffer.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DataBuffer } from '@/services/ble/DataBuffer';
|
||||
import type { NormalizedReading } from '@/services/ble/types';
|
||||
|
||||
// Mock Taro Storage
|
||||
const storage = new Map<string, string>();
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
getStorageInfoSync: vi.fn(() => ({ keys: Array.from(storage.keys()), limitSize: 10240, currentSize: storage.size })),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeReading(overrides: Partial<NormalizedReading> = {}): NormalizedReading {
|
||||
return {
|
||||
device_type: 'heart_rate',
|
||||
values: { heart_rate: 72 },
|
||||
measured_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataBuffer', () => {
|
||||
let buffer: DataBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
buffer = new DataBuffer({ bucketSize: 100 });
|
||||
});
|
||||
|
||||
it('push 添加读数并持久化', () => {
|
||||
const reading = makeReading();
|
||||
buffer.push(reading);
|
||||
expect(buffer.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('push 批量添加读数', () => {
|
||||
const readings = Array.from({ length: 10 }, (_, i) =>
|
||||
makeReading({ measured_at: new Date(Date.now() + i * 1000).toISOString() }),
|
||||
);
|
||||
buffer.push(readings);
|
||||
expect(buffer.size()).toBe(10);
|
||||
});
|
||||
|
||||
it('flush 返回并清空缓冲区', () => {
|
||||
buffer.push([
|
||||
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
|
||||
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
|
||||
]);
|
||||
const flushed = buffer.flush();
|
||||
expect(flushed.length).toBe(2);
|
||||
expect(buffer.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('超过 maxTotal 时丢弃最旧数据', () => {
|
||||
const smallBuffer = new DataBuffer({ bucketSize: 5, maxTotal: 10 });
|
||||
for (let i = 0; i < 15; i++) {
|
||||
smallBuffer.push(makeReading({ measured_at: new Date(i * 1000).toISOString() }));
|
||||
}
|
||||
expect(smallBuffer.size()).toBe(10);
|
||||
});
|
||||
|
||||
it('去重:相同 measured_at + device_type 不重复存储', () => {
|
||||
const ts = '2026-05-04T10:00:00.000Z';
|
||||
buffer.push(makeReading({ measured_at: ts }));
|
||||
buffer.push(makeReading({ measured_at: ts }));
|
||||
expect(buffer.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('restore 从 Storage 恢复未上传数据', () => {
|
||||
buffer.push([
|
||||
makeReading({ measured_at: '2026-05-04T10:00:00.000Z' }),
|
||||
makeReading({ measured_at: '2026-05-04T10:00:01.000Z' }),
|
||||
]);
|
||||
// 模拟重启:新建 DataBuffer 并 restore
|
||||
const restored = new DataBuffer({ bucketSize: 100 });
|
||||
const count = restored.restore();
|
||||
expect(count).toBe(2);
|
||||
expect(restored.size()).toBe(2);
|
||||
});
|
||||
|
||||
it('clear 清空缓冲区和 Storage', () => {
|
||||
buffer.push(makeReading());
|
||||
buffer.clear();
|
||||
expect(buffer.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) || ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DataSyncScheduler', () => {
|
||||
let scheduler: DataSyncScheduler;
|
||||
let syncFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 5 });
|
||||
scheduler = new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
storageKey: 'last_ble_sync',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.destroy();
|
||||
});
|
||||
|
||||
it('首次同步:无记录时立即需要同步', () => {
|
||||
expect(scheduler.needsSync()).toBe(true);
|
||||
});
|
||||
|
||||
it('同步后记录时间戳', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
expect(storage.has('last_ble_sync')).toBe(true);
|
||||
expect(syncFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('同步后不需要再次同步', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
expect(scheduler.needsSync()).toBe(false);
|
||||
});
|
||||
|
||||
it('超过间隔后需要再次同步', async () => {
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: twoHoursAgo }));
|
||||
scheduler = new DataSyncScheduler({ intervalMs: 60 * 60 * 1000, storageKey: 'last_ble_sync' });
|
||||
|
||||
expect(scheduler.needsSync()).toBe(true);
|
||||
});
|
||||
|
||||
it('同步失败不更新时间戳', async () => {
|
||||
const failFn = vi.fn().mockRejectedValue(new Error('network error'));
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: oneHourAgo }));
|
||||
|
||||
await scheduler.recordSync(failFn);
|
||||
const stored = JSON.parse(storage.get('last_ble_sync') || '{}');
|
||||
expect(stored.lastSyncAt).toBe(oneHourAgo);
|
||||
});
|
||||
|
||||
it('tryAutoSync 首次时触发同步', async () => {
|
||||
const result = await scheduler.tryAutoSync(syncFn);
|
||||
expect(result).toBe(true);
|
||||
expect(syncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tryAutoSync 未超时不触发', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
syncFn.mockClear();
|
||||
const result = await scheduler.tryAutoSync(syncFn);
|
||||
expect(result).toBe(false);
|
||||
expect(syncFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroy 清理定时器', () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
scheduler.startPeriodicCheck(syncFn, 30000);
|
||||
scheduler.destroy();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('getLastSyncAt 返回上次同步时间', async () => {
|
||||
await scheduler.recordSync(syncFn);
|
||||
const lastSync = scheduler.getLastSyncAt();
|
||||
expect(lastSync).toBeTruthy();
|
||||
expect(typeof lastSync).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createGenericBleAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import type { GenericBLEProfile } from '@/services/ble/types';
|
||||
|
||||
// ---- Heart Rate (0x180D / 0x2A37) ----
|
||||
// Flag byte=0x00 (UINT8), HR=75
|
||||
function makeHeartRateData(hr: number, isUint16 = false): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(isUint16 ? 3 : 2);
|
||||
const view = new DataView(buf);
|
||||
view.setUint8(0, isUint16 ? 0x01 : 0x00);
|
||||
if (isUint16) {
|
||||
view.setUint16(1, hr, true);
|
||||
} else {
|
||||
view.setUint8(1, hr);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- Health Thermometer (0x1809 / 0x2A1C) ----
|
||||
// IEEE 11073 FLOAT: 32-bit — mantissa (24-bit) + exponent (8-bit)
|
||||
function makeTemperatureData(tempCelsius: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(4);
|
||||
const view = new DataView(buf);
|
||||
// flags byte: 0x00 = Celsius, no timestamp, no type
|
||||
view.setUint8(0, 0x00);
|
||||
// 11073 FLOAT: mantissa * 10^exponent
|
||||
// For 36.5: mantissa=365, exponent=-1
|
||||
const mantissa = Math.round(tempCelsius * 10);
|
||||
const exponent = -1;
|
||||
view.setInt16(1, mantissa, true);
|
||||
view.setInt8(3, exponent);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe('GenericBleAdapter', () => {
|
||||
describe('心率解析', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Test Wristband',
|
||||
supportedModels: ['TestBand'],
|
||||
profiles: ['heart_rate'],
|
||||
});
|
||||
|
||||
it('解析 UINT8 心率', () => {
|
||||
const data = makeHeartRateData(75);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].device_type).toBe('heart_rate');
|
||||
expect(results[0].values.heart_rate).toBe(75);
|
||||
});
|
||||
|
||||
it('解析 UINT16 心率', () => {
|
||||
const data = makeHeartRateData(200, true);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].values.heart_rate).toBe(200);
|
||||
});
|
||||
|
||||
it('忽略非目标 Characteristic', () => {
|
||||
const data = makeHeartRateData(75);
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A38-0000-1000-8000-00805f9b34fb', // Body Sensor Location
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('体温解析', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Test Thermometer',
|
||||
supportedModels: ['TestThermo'],
|
||||
profiles: ['health_thermometer'],
|
||||
});
|
||||
|
||||
it('解析体温读数', () => {
|
||||
const data = makeTemperatureData(36.5);
|
||||
const results = adapter.parseNotification(
|
||||
'00001809-0000-1000-8000-00805f9b34fb',
|
||||
'00002A1C-0000-1000-8000-00805f9b34fb',
|
||||
data,
|
||||
);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].device_type).toBe('temperature');
|
||||
expect(results[0].values.value).toBeCloseTo(36.5, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('多 Profile 适配器', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Multi-Profile Band',
|
||||
supportedModels: ['CustomBand', 'MedicalBand'],
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
it('包含两个 Service UUID', () => {
|
||||
expect(adapter.serviceUUIDs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('包含两个 Profile 的 Characteristic', () => {
|
||||
expect(adapter.notifyCharacteristics.length).toBe(2);
|
||||
});
|
||||
|
||||
it('supportedModels 配置正确', () => {
|
||||
expect(adapter.supportedModels).toEqual(['CustomBand', 'MedicalBand']);
|
||||
});
|
||||
|
||||
it('解析心率 + 体温', () => {
|
||||
const hrResults = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
makeHeartRateData(80),
|
||||
);
|
||||
expect(hrResults[0].device_type).toBe('heart_rate');
|
||||
|
||||
const tempResults = adapter.parseNotification(
|
||||
'00001809-0000-1000-8000-00805f9b34fb',
|
||||
'00002A1C-0000-1000-8000-00805f9b34fb',
|
||||
makeTemperatureData(37.2),
|
||||
);
|
||||
expect(tempResults[0].device_type).toBe('temperature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
const adapter = createGenericBleAdapter({
|
||||
name: 'Edge Case Band',
|
||||
supportedModels: ['Edge'],
|
||||
profiles: ['heart_rate'],
|
||||
});
|
||||
|
||||
it('空数据返回空数组', () => {
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
new ArrayBuffer(0),
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('心率超范围 (>300) 返回空数组', () => {
|
||||
const results = adapter.parseNotification(
|
||||
'0000180D-0000-1000-8000-00805f9b34fb',
|
||||
'00002A37-0000-1000-8000-00805f9b34fb',
|
||||
makeHeartRateData(0),
|
||||
);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user