Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
334 changes: 334 additions & 0 deletions docs/adr/ADR-043-sensing-server-ui-api-completion.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SensingTab } from './components/SensingTab.js';
import { apiService } from './services/api.service.js';
import { wsService } from './services/websocket.service.js';
import { healthService } from './services/health.service.js';
import { sensingService } from './services/sensing.service.js';
import { backendDetector } from './utils/backend-detector.js';

class WiFiDensePoseApp {
Expand Down Expand Up @@ -75,6 +76,10 @@ class WiFiDensePoseApp {
console.warn('⚠️ Backend not available:', error.message);
this.showBackendStatus('Backend unavailable — start sensing-server', 'warning');
}

// Start the sensing WebSocket service early so the dashboard and
// live-demo tabs can show the correct data-source status immediately.
sensingService.start();
}
}

Expand Down
10 changes: 8 additions & 2 deletions ui/mobile/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
const expoPreset = require('jest-expo/jest-preset');

module.exports = {
preset: 'jest-expo',
setupFiles: [
'<rootDir>/jest.setup.pre.js',
...(expoPreset.setupFiles || []),
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/src/__tests__/'],
testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'],
transformIgnorePatterns: [
'node_modules/(?!(expo|expo-.+|react-native|@react-native|react-native-webview|react-native-reanimated|react-native-svg|react-native-safe-area-context|react-native-screens|@react-navigation|@expo|@unimodules|expo-modules-core)/)',
'node_modules/(?!(expo|expo-.+|react-native|@react-native|react-native-webview|react-native-reanimated|react-native-svg|react-native-safe-area-context|react-native-screens|@react-navigation|@expo|@unimodules|expo-modules-core|react-native-worklets)/)',
],
};
38 changes: 38 additions & 0 deletions ui/mobile/jest.setup.pre.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Pre-define globals that expo/src/winter/runtime.native.ts would lazily
// install via require()-with-ESM-import, which jest 30 rejects.
// By defining them upfront as non-configurable, the `install()` function
// in installGlobal.ts will skip them with a console.error (which is harmless).
const globalsToProtect = [
'TextDecoder',
'TextDecoderStream',
'TextEncoderStream',
'URL',
'URLSearchParams',
'__ExpoImportMetaRegistry',
'structuredClone',
];

for (const name of globalsToProtect) {
if (globalThis[name] !== undefined) {
// Already defined (e.g. Node provides URL, TextDecoder, structuredClone).
// Make it non-configurable so expo's install() skips it.
try {
Object.defineProperty(globalThis, name, {
value: globalThis[name],
configurable: false,
enumerable: true,
writable: true,
});
} catch {
// Already non-configurable, fine.
}
} else {
// Not yet defined, set a stub value and make non-configurable.
Object.defineProperty(globalThis, name, {
value: name === '__ExpoImportMetaRegistry' ? { url: 'http://localhost:8081' } : undefined,
configurable: false,
enumerable: false,
writable: true,
});
}
}
3 changes: 3 additions & 0 deletions ui/mobile/src/__tests__/__mocks__/getBundleUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
getBundleUrl: () => 'http://localhost:8081',
};
7 changes: 7 additions & 0 deletions ui/mobile/src/__tests__/__mocks__/importMetaRegistry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
ImportMetaRegistry: {
get url() {
return 'http://localhost:8081';
},
},
};
37 changes: 34 additions & 3 deletions ui/mobile/src/__tests__/components/ConnectionBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { ConnectionBanner } from '@/components/ConnectionBanner';
import { ThemeProvider } from '@/theme/ThemeContext';

const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider>{ui}</ThemeProvider>);

describe('ConnectionBanner', () => {
it('renders LIVE STREAM text when connected', () => {
renderWithTheme(<ConnectionBanner status="connected" />);
expect(screen.getByText('LIVE STREAM')).toBeTruthy();
});

it('renders DISCONNECTED text when disconnected', () => {
renderWithTheme(<ConnectionBanner status="disconnected" />);
expect(screen.getByText('DISCONNECTED')).toBeTruthy();
});

it('renders SIMULATED DATA text when simulated', () => {
renderWithTheme(<ConnectionBanner status="simulated" />);
expect(screen.getByText('SIMULATED DATA')).toBeTruthy();
});

it('renders without crashing for each status', () => {
const statuses: Array<'connected' | 'simulated' | 'disconnected'> = [
'connected',
'simulated',
'disconnected',
];
for (const status of statuses) {
const { unmount } = renderWithTheme(<ConnectionBanner status={status} />);
unmount();
}
});
});
64 changes: 61 additions & 3 deletions ui/mobile/src/__tests__/components/GaugeArc.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import React from 'react';
import { render } from '@testing-library/react-native';
import { ThemeProvider } from '@/theme/ThemeContext';

jest.mock('react-native-svg', () => {
const { View } = require('react-native');
return {
__esModule: true,
default: View, // Svg
Svg: View,
Circle: View,
G: View,
Text: View,
Rect: View,
Line: View,
Path: View,
};
});

// GaugeArc uses Animated.createAnimatedComponent(Circle), so we need
// the reanimated mock (already in jest.setup.ts) and SVG mock above.
import { GaugeArc } from '@/components/GaugeArc';

const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider>{ui}</ThemeProvider>);

describe('GaugeArc', () => {
it('renders without crashing', () => {
const { toJSON } = renderWithTheme(
<GaugeArc value={50} max={100} label="BPM" unit="bpm" color="#00FF00" />,
);
expect(toJSON()).not.toBeNull();
});

it('renders with min and max values', () => {
const { toJSON } = renderWithTheme(
<GaugeArc value={0} min={0} max={200} label="Test" unit="x" color="#FF0000" />,
);
expect(toJSON()).not.toBeNull();
});

it('renders with colorTo gradient', () => {
const { toJSON } = renderWithTheme(
<GaugeArc
value={75}
max={100}
label="HR"
unit="bpm"
color="#00FF00"
colorTo="#FF0000"
size={200}
/>,
);
expect(toJSON()).not.toBeNull();
});

it('renders with custom size', () => {
const { toJSON } = renderWithTheme(
<GaugeArc value={30} max={60} label="BR" unit="brpm" color="#0088FF" size={80} />,
);
expect(toJSON()).not.toBeNull();
});
});
17 changes: 14 additions & 3 deletions ui/mobile/src/__tests__/components/HudOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
// HudOverlay.tsx is an empty file (0 bytes). This test verifies that importing
// it does not throw and that the module exists.

describe('HudOverlay', () => {
it('module can be imported without error', () => {
expect(() => {
require('@/components/HudOverlay');
}).not.toThrow();
});

it('module exports are defined (may be empty)', () => {
const mod = require('@/components/HudOverlay');
// The module is empty, so it should be an object (possibly with no exports)
expect(typeof mod).toBe('object');
});
});
63 changes: 60 additions & 3 deletions ui/mobile/src/__tests__/components/OccupancyGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import React from 'react';
import { render } from '@testing-library/react-native';
import { ThemeProvider } from '@/theme/ThemeContext';

jest.mock('react-native-svg', () => {
const { View } = require('react-native');
return {
__esModule: true,
default: View,
Svg: View,
Circle: View,
G: View,
Text: View,
Rect: View,
Line: View,
Path: View,
};
});

import { OccupancyGrid } from '@/components/OccupancyGrid';

const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider>{ui}</ThemeProvider>);

describe('OccupancyGrid', () => {
it('renders without crashing with empty values', () => {
const { toJSON } = renderWithTheme(<OccupancyGrid values={[]} />);
expect(toJSON()).not.toBeNull();
});

it('renders with a full 400-element values array', () => {
const values = new Array(400).fill(0.5);
const { toJSON } = renderWithTheme(<OccupancyGrid values={values} />);
expect(toJSON()).not.toBeNull();
});

it('renders with person positions', () => {
const values = new Array(400).fill(0.3);
const positions = [
{ x: 5, y: 5 },
{ x: 15, y: 10 },
];
const { toJSON } = renderWithTheme(
<OccupancyGrid values={values} personPositions={positions} />,
);
expect(toJSON()).not.toBeNull();
});

it('renders with custom size', () => {
const values = new Array(400).fill(0);
const { toJSON } = renderWithTheme(
<OccupancyGrid values={values} size={200} />,
);
expect(toJSON()).not.toBeNull();
});

it('handles values outside 0-1 range by clamping', () => {
const values = [-0.5, 0, 0.5, 1.5, NaN, 2, ...new Array(394).fill(0)];
const { toJSON } = renderWithTheme(<OccupancyGrid values={values} />);
expect(toJSON()).not.toBeNull();
});
});
47 changes: 44 additions & 3 deletions ui/mobile/src/__tests__/components/SignalBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { SignalBar } from '@/components/SignalBar';
import { ThemeProvider } from '@/theme/ThemeContext';

const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider>{ui}</ThemeProvider>);

describe('SignalBar', () => {
it('renders the label text', () => {
renderWithTheme(<SignalBar value={0.5} label="Signal Strength" />);
expect(screen.getByText('Signal Strength')).toBeTruthy();
});

it('renders the percentage text', () => {
renderWithTheme(<SignalBar value={0.75} label="Test" />);
expect(screen.getByText('75%')).toBeTruthy();
});

it('clamps value at 0 for negative input', () => {
renderWithTheme(<SignalBar value={-0.5} label="Low" />);
expect(screen.getByText('0%')).toBeTruthy();
});

it('clamps value at 100 for input above 1', () => {
renderWithTheme(<SignalBar value={1.5} label="High" />);
expect(screen.getByText('100%')).toBeTruthy();
});

it('renders without crashing with custom color', () => {
const { toJSON } = renderWithTheme(
<SignalBar value={0.5} label="Custom" color="#FF0000" />,
);
expect(toJSON()).not.toBeNull();
});

it('renders 0% for zero value', () => {
renderWithTheme(<SignalBar value={0} label="Zero" />);
expect(screen.getByText('0%')).toBeTruthy();
});

it('renders 100% for value of 1', () => {
renderWithTheme(<SignalBar value={1} label="Full" />);
expect(screen.getByText('100%')).toBeTruthy();
});
});
55 changes: 52 additions & 3 deletions ui/mobile/src/__tests__/components/SparklineChart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import React from 'react';
import { render } from '@testing-library/react-native';
import { SparklineChart } from '@/components/SparklineChart';
import { ThemeProvider } from '@/theme/ThemeContext';

const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider>{ui}</ThemeProvider>);

describe('SparklineChart', () => {
it('renders without crashing with data points', () => {
const { toJSON } = renderWithTheme(
<SparklineChart data={[-50, -45, -48, -42, -47]} />,
);
expect(toJSON()).not.toBeNull();
});

it('renders with empty data array', () => {
const { toJSON } = renderWithTheme(<SparklineChart data={[]} />);
expect(toJSON()).not.toBeNull();
});

it('renders with single data point', () => {
const { toJSON } = renderWithTheme(<SparklineChart data={[42]} />);
expect(toJSON()).not.toBeNull();
});

it('renders with custom color', () => {
const { toJSON } = renderWithTheme(
<SparklineChart data={[1, 2, 3]} color="#FF0000" />,
);
expect(toJSON()).not.toBeNull();
});

it('renders with custom height', () => {
const { toJSON } = renderWithTheme(
<SparklineChart data={[1, 2, 3]} height={100} />,
);
expect(toJSON()).not.toBeNull();
});

it('has an image accessibility role', () => {
const { getByRole } = renderWithTheme(
<SparklineChart data={[1, 2, 3]} />,
);
expect(getByRole('image')).toBeTruthy();
});

it('renders with all identical values', () => {
const { toJSON } = renderWithTheme(
<SparklineChart data={[5, 5, 5, 5, 5]} />,
);
expect(toJSON()).not.toBeNull();
});
});
Loading