Skip to content
118 changes: 96 additions & 22 deletions src/deviceDiscovery/DeviceManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,60 @@ describe('DeviceManager', () => {
});
});

describe('scan', () => {
it('triggers discovery without health checking', () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

const discoverAllSpy = sinon.spy(manager as any, 'discoverAll');
const checkDevicesHealthSpy = sinon.spy(manager as any, 'checkDevicesHealth');

manager.scan(true);

expect(discoverAllSpy.calledOnce).to.be.true;
expect(checkDevicesHealthSpy.called).to.be.false;
});

it('respects deviceDiscoveryEnabled when force=false', () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);
sinon.stub(manager as any, 'deviceDiscoveryEnabled').get(() => false);

const discoverAllSpy = sinon.spy(manager as any, 'discoverAll');

const result = manager.scan(false);

expect(result).to.be.false;
expect(discoverAllSpy.called).to.be.false;
});

it('ignores deviceDiscoveryEnabled when force=true', () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);
sinon.stub(manager as any, 'deviceDiscoveryEnabled').get(() => false);

const discoverAllSpy = sinon.spy(manager as any, 'discoverAll');

const result = manager.scan(true);

expect(result).to.be.true;
expect(discoverAllSpy.calledOnce).to.be.true;
});

it('emits scan-started event', () => {
const clock = sinon.useFakeTimers();
try {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

const scanStartedSpy = sinon.spy();
manager.on('scan-started', scanStartedSpy);

manager.scan(true);

expect(scanStartedSpy.calledOnce).to.be.true;
} finally {
clock.restore();
}
});
});

describe('checkDevicesHealth', () => {
it('sets all devices to pending and checks all when force=true', async () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);
Expand Down Expand Up @@ -858,7 +912,7 @@ describe('DeviceManager', () => {
expect(manager['devices'].some(d => d.serialNumber === 'cached-device')).to.be.true;
});

it('loads cached devices as pending state', () => {
it('loads cached devices as online when cache is fresh (within 5 minutes)', () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

mockGlobalStateManager.getLastSeenDevices.returns(['device-1']);
Expand All @@ -868,7 +922,27 @@ describe('DeviceManager', () => {
'default-device-name': 'Test Roku',
'serial-number': 'device-1'
},
createdAt: Date.now()
createdAt: Date.now() // Fresh cache
});
// Mock getIpForSerial to return the IP
mockGlobalStateManager.getIpForSerial = sinon.stub().returns('192.168.1.100');

manager['loadLastSeenDevices']();

expect(manager['devices'][0].deviceState).to.equal('online');
});

it('loads cached devices as pending when cache is stale (older than 5 minutes)', () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

mockGlobalStateManager.getLastSeenDevices.returns(['device-1']);
mockGlobalStateManager.getCachedDevice.returns({
serialNumber: 'device-1',
deviceInfo: {
'default-device-name': 'Test Roku',
'serial-number': 'device-1'
},
createdAt: Date.now() - (6 * 60 * 1_000) // 6 minutes ago - stale
});
// Mock getIpForSerial to return the IP
mockGlobalStateManager.getIpForSerial = sinon.stub().returns('192.168.1.100');
Expand Down Expand Up @@ -1163,7 +1237,7 @@ describe('DeviceManager', () => {
});
});

describe('getDeviceInfoCached', () => {
describe('fetchDeviceInfo', () => {
it('only makes one network call for rapid successive requests', async () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

Expand All @@ -1174,8 +1248,8 @@ describe('DeviceManager', () => {
} as any);

// Call twice in rapid succession
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);

// Should only have made one actual network call
expect(getDeviceInfoStub.callCount).to.equal(1);
Expand All @@ -1192,14 +1266,14 @@ describe('DeviceManager', () => {
} as any);

// First call - should hit network
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(1);

// Advance past TTL (5 seconds)
clock.tick(6_000);

// Second call - cache expired, should hit network again
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(2);
} finally {
clock.restore();
Expand All @@ -1216,15 +1290,15 @@ describe('DeviceManager', () => {
} as any);

// Call for two different IPs
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['getDeviceInfoCached']('192.168.1.101', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.101', 8060);

// Should make two network calls (different IPs)
expect(getDeviceInfoStub.callCount).to.equal(2);

// But calling same IPs again should use cache
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['getDeviceInfoCached']('192.168.1.101', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.101', 8060);

// Still only two calls
expect(getDeviceInfoStub.callCount).to.equal(2);
Expand All @@ -1241,19 +1315,19 @@ describe('DeviceManager', () => {
} as any);

// First call - populates cache
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(1);

// Call again within TTL - should use cache
clock.tick(2_000);
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(1);

// Advance past cleanup delay (10 seconds of inactivity)
clock.tick(11_000);

// Cache should be cleared, next call hits network
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(2);
} finally {
clock.restore();
Expand All @@ -1270,18 +1344,18 @@ describe('DeviceManager', () => {
} as any);

// Populate cache
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(1);

// Verify cache is working
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(1);

// Simulate network change by calling the networkChangeMonitor callback
manager['networkChangeMonitor']['onNetworkChanged']();

// Cache should be cleared, next call hits network
await manager['getDeviceInfoCached']('192.168.1.100', 8060);
await manager['fetchDeviceInfo']('192.168.1.100', 8060);
expect(getDeviceInfoStub.callCount).to.equal(2);
});
});
Expand Down Expand Up @@ -1733,27 +1807,27 @@ describe('DeviceManager', () => {
});

describe('timer clearing', () => {
it('clears cacheCleanupTimer', () => {
it('clears fetchDeviceInfoThrottleTimer', () => {
const clock = sinon.useFakeTimers();
try {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

// Trigger cache cleanup timer by resetting it
manager['resetCacheCleanupTimer']();
expect(manager['cacheCleanupTimer']).to.not.be.null;
expect(manager['fetchDeviceInfoThrottleTimer']).to.not.be.null;

manager.clearAllCache();

expect(manager['cacheCleanupTimer']).to.be.null;
expect(manager['fetchDeviceInfoThrottleTimer']).to.be.null;
} finally {
clock.restore();
}
});

it('does not throw if cacheCleanupTimer is already null', () => {
it('does not throw if fetchDeviceInfoThrottleTimer is already null', () => {
manager = new DeviceManager(vscode.context, mockGlobalStateManager);

manager['cacheCleanupTimer'] = null;
manager['fetchDeviceInfoThrottleTimer'] = null;

expect(() => manager.clearAllCache()).to.not.throw();
});
Expand Down
Loading