Skip to content
Merged
105 changes: 0 additions & 105 deletions __tests__/pro/locketPlayerSmoke.test.tsx

This file was deleted.

84 changes: 84 additions & 0 deletions __tests__/pro/locketSpeakerTurns.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Speaker turns render on the recording DETAIL (rendered integration) — the Locket recorder.
*
* tdrz transcription marks voice changes with a [SPEAKER_TURN] token in the segment text. The
* detail screen must turn those into visible "Speaker 1 / Speaker 2" labels above the transcript
* rows. This is the screen search + a normal tap both land on (the old LocketPlayer that used to
* render turns was removed and its mapping ported here), so this guards that the diarization
* actually shows where the user reaches it.
*
* Real detail screen on a real nav stack; the recording (analysed, with tdrz segments) is seeded
* via the store's real writers (the device-leaf). Parameterized so both branches falsify: with
* [SPEAKER_TURN] markers the labels appear; without them, no "Speaker" label renders.
*
* Placement: __tests__/pro/ (the canonical home for pro-importing rendered tests).
*/
jest.mock('@react-navigation/native', () => jest.requireActual('@react-navigation/native'));

import { installNativeBoundary, requireRTL } from '../harness/nativeBoundary';
import { installPro } from '../harness/proHarness';

const CASES: { name: string; withTurns: boolean }[] = [
{ name: 'tdrz [SPEAKER_TURN] markers → Speaker 1/2 labels', withTurns: true },
{ name: 'no markers → no speaker labels', withTurns: false },
];

describe('speaker turns on the recording detail (rendered)', () => {
it.each(CASES)('$name', async ({ withTurns }) => {
const boundary = installNativeBoundary({ fs: true });
/* eslint-disable @typescript-eslint/no-var-requires */
const React = require('react');
const { render, fireEvent, waitFor, act } = requireRTL();
await installPro();
const { NavigationContainer } = require('@react-navigation/native');
const { createNativeStackNavigator } = require('@react-navigation/native-stack');
const { getRegisteredScreens } = require('../../src/navigation/screenRegistry');
const { useRecordingsStore } = require('@offgrid/pro/locket/stores');
/* eslint-enable @typescript-eslint/no-var-requires */

useRecordingsStore.setState({ recordings: [], jobs: [] });
const NOW = Date.now();
boundary.fs!.seedFile('/docs/rec.wav', 5_000_000);
useRecordingsStore.getState().addFinalized({
path: '/docs/rec.wav', startedAt: NOW, endedAt: NOW,
durationMs: 30 * 60 * 1000, sizeBytes: 5_000_000,
});
const id: string = useRecordingsStore.getState().recordings[0].id;
// A [SPEAKER_TURN] on the middle segment flips the speaker, so the first row reads "Speaker 1"
// and the last "Speaker 2". Analysed (on-device) so the detail shows its transcript body rather
// than the Analyse CTA. The stripped segment text stays non-empty so the rows render.
const turn = withTurns ? ' [SPEAKER_TURN]' : '';
useRecordingsStore.getState().updateRecording(id, {
transcript: 'alpha. beta. gamma.',
transcriptStatus: 'done', transcribedAt: NOW, prunedAt: NOW,
transcriptSegments: [
{ text: 'alpha.', startMs: 0, endMs: 1000 },
{ text: `beta.${turn}`, startMs: 1000, endMs: 2000 },
{ text: 'gamma.', startMs: 2000, endMs: 3000 },
],
insightsSource: 'on-device', insightsAt: 1, title: 'T', summary: 'S', keyPoints: ['k'],
actionItems: [{ id: 'a', text: 'x' }],
} as never);

const Stack = createNativeStackNavigator();
const screens = getRegisteredScreens();
const App = () => React.createElement(NavigationContainer, null,
React.createElement(Stack.Navigator, { initialRouteName: 'LocketRecording', screenOptions: { headerShown: false } },
...screens.map((sc: { name: string; component: React.ComponentType }) =>
React.createElement(Stack.Screen, {
key: sc.name, name: sc.name, component: sc.component,
initialParams: sc.name === 'LocketRecording' ? { recordingId: id } : undefined,
}))));
const ui = render(React.createElement(App));
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });

// Transcript is collapsed by default — expand it so the segment rows (+ speaker labels) render.
await waitFor(() => ui.getByTestId('insights-toggle-transcript'));
await act(async () => { fireEvent.press(ui.getByTestId('insights-toggle-transcript')); await Promise.resolve(); });
await waitFor(() => ui.getByTestId('transcript-seg-0'));

// The terminal artifact: visible "Speaker N" labels iff the tdrz markers were present.
expect(ui.queryByText('Speaker 1') != null).toBe(withTurns);
expect(ui.queryByText('Speaker 2') != null).toBe(withTurns);
});
});
123 changes: 123 additions & 0 deletions __tests__/pro/ui/LocketRecordingRepair.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* LocketRecordingScreen - damaged-clip Repair (Pro) integration test.
*
* Drives the REAL LocketRecordingScreen + REAL useRecordingDetail + REAL
* repairRecording service against the REAL recordings store. Fakes ONLY device
* boundaries: the vector-icon shims, theme, navigation, toast, and the native
* AudioNormalizer.repairWavHeader / RecordingPlayer modules. Asserts what the
* user sees: a recovered clip with a stale header shows a Repair banner, and
* tapping Repair clears it (the header got rewritten -> needsRepair false).
*
* Skips in open-core CI where pro/ is absent.
*/

import React from 'react';
import { NativeModules } from 'react-native';
import { render, fireEvent, waitFor } from '@testing-library/react-native';

const mockColors = {
text: '#000', textMuted: '#999', textSecondary: '#666', textDisabled: '#bbb',
primary: '#1DB954', error: '#E00', trending: '#F90',
background: '#FFF', surface: '#F5F5F5', surfaceLight: '#EEE', border: '#E0E0E0', overlay: 'rgba(0,0,0,0.4)',
};

jest.mock('react-native-vector-icons/Feather', () => () => null);
jest.mock('react-native-vector-icons/MaterialIcons', () => () => null);

const mockShadows = new Proxy({}, { get: () => ({}) });
jest.mock('@offgrid/core/theme', () => {
const actual = jest.requireActual('@offgrid/core/theme');
return {
...actual,
useTheme: () => ({ colors: mockColors, shadows: mockShadows, isDark: false }),
// Real useThemedStyles calls factory(colors, shadows) - mirror that here.
useThemedStyles: (fn: (c: typeof mockColors, s: unknown) => unknown) => fn(mockColors, mockShadows),
};
});

jest.mock('@offgrid/core/utils/toast', () => ({ showToast: jest.fn() }));

jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
return {
...actual,
useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }),
useRoute: () => ({ params: { recordingId: 'rec-1' } }),
};
});

// Native boundary: the header repair + the player module the detail screen mounts.
const repairMock = jest.fn().mockResolvedValue({ alreadyHealthy: false, durationMs: 5000, sizeBytes: 100000 });
(NativeModules as unknown as Record<string, unknown>).AudioNormalizer = {
repairWavHeader: repairMock,
normalizeToWav16kMono: jest.fn().mockResolvedValue('/tmp/x.wav'),
};
(NativeModules as unknown as Record<string, unknown>).RecordingPlayer = {
addListener: jest.fn(),
removeListeners: jest.fn(),
load: jest.fn().mockResolvedValue(undefined),
play: jest.fn(),
pause: jest.fn(),
seek: jest.fn(),
stop: jest.fn(),
};

type ScreenModule = typeof import('@offgrid/pro/locket/screens/LocketRecordingScreen');
type StoreModule = typeof import('@offgrid/pro/locket/stores');

function load(): { screen: ScreenModule; store: StoreModule } | null {
try {
return {
screen: require(['..', '..', '..', 'pro', 'locket', 'screens', 'LocketRecordingScreen'].join('/')),
store: require(['..', '..', '..', 'pro', 'locket', 'stores'].join('/')),
};
} catch {
return null;
}
}

const mods = load();
const maybe = mods ? describe : describe.skip;

maybe('LocketRecordingScreen - damaged clip repair', () => {
const { LocketRecordingScreen } = mods!.screen;
const { useRecordingsStore } = mods!.store;
const initial = useRecordingsStore.getState();
const now = Date.now();

const damaged = {
id: 'rec-1',
path: '/tmp/rec-1.wav',
startedAt: now - 60_000,
endedAt: now,
durationMs: 60_000,
sizeBytes: 500_000,
prunedAt: now,
name: 'Recovered (header damaged)',
// The recovered-with-stale-header signal set by recovery.
needsRepair: true,
};

beforeEach(() => {
repairMock.mockClear();
useRecordingsStore.setState({ recordings: [damaged] });
});
afterEach(() => {
useRecordingsStore.setState(initial, true);
});

it('shows the Repair banner for a damaged clip and clears it after repair', async () => {
const { getByTestId, queryByTestId, getByText } = render(<LocketRecordingScreen />);

// The user sees the banner - the clip won't play until the header is fixed.
expect(getByTestId('repair-banner')).toBeTruthy();
expect(getByText(/Recovered after the app closed/i)).toBeTruthy();

// Tapping Repair dispatches the real repairRecording service (native header fix).
fireEvent.press(getByTestId('repair-header'));
await waitFor(() => expect(repairMock).toHaveBeenCalledWith('/tmp/rec-1.wav'));

// Header rewritten -> needsRepair cleared -> the banner is gone.
await waitFor(() => expect(queryByTestId('repair-banner')).toBeNull());
});
});
4 changes: 2 additions & 2 deletions __tests__/unit/services/whisperService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,10 @@ describe('WhisperService', () => {
const mockContext = { id: 'ctx', release: jest.fn(), transcribeRealtime: jest.fn(), transcribe: jest.fn() };
mockedInitWhisper.mockResolvedValue(mockContext as any);

await whisperService.loadModel('/path/to/model.bin', { useCoreML: true, useGpu: true, useFlashAttn: true });
await whisperService.loadModel('/path/to/model.bin', { useCoreML: true });

expect(initWhisper).toHaveBeenCalledWith(
expect.objectContaining({ useCoreMLIos: true, useGpu: true, useFlashAttn: true }),
expect.objectContaining({ useCoreMLIos: true }),
);
});

Expand Down
30 changes: 30 additions & 0 deletions patches/react-native-calendar-events+2.2.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
diff --git a/node_modules/react-native-calendar-events/ios/RNCalendarEvents.m b/node_modules/react-native-calendar-events/ios/RNCalendarEvents.m
index a85c013..e36e344 100644
--- a/node_modules/react-native-calendar-events/ios/RNCalendarEvents.m
+++ b/node_modules/react-native-calendar-events/ios/RNCalendarEvents.m
@@ -761,14 +761,23 @@ - (NSDictionary *)serializeCalendarEvent:(EKEvent *)event

RCT_EXPORT_METHOD(requestPermissions:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
{
- [self.eventStore requestAccessToEntityType:EKEntityTypeEvent completion:^(BOOL granted, NSError *error) {
+ void (^handler)(BOOL, NSError *) = ^(BOOL granted, NSError *error) {
NSString *status = granted ? @"authorized" : @"denied";
if (!error) {
resolve(status);
} else {
reject(@"error", @"authorization request error", error);
}
- }];
+ };
+ // iOS 17+ made requestAccessToEntityType "no longer allowed" for reads; reading
+ // events requires the new full-access API (requestFullAccessToEventsWithCompletion).
+ // Fall back to the old API on iOS 16 and earlier. Patched here because
+ // react-native-calendar-events is unmaintained (latest 2.2.0, Jan 2021, predates iOS 17).
+ if (@available(iOS 17.0, *)) {
+ [self.eventStore requestFullAccessToEventsWithCompletion:handler];
+ } else {
+ [self.eventStore requestAccessToEntityType:EKEntityTypeEvent completion:handler];
+ }
}

RCT_EXPORT_METHOD(findCalendars:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
10 changes: 10 additions & 0 deletions src/components/ModelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ interface ModelCardProps {
recommended?: RecommendedConfig;
/** Model can run on the GPU/NPU (LiteRT or Q4_0/Q8_0 GGUF) → show the badge. */
supportsAcceleration?: boolean;
/**
* iOS whisper only: state of this model's CoreML (Neural Engine) encoder.
* 'ready' = encoder present & valid (runs on the ANE); 'unavailable' = downloaded but
* no valid encoder (runs on CPU). Undefined = don't show (non-iOS, not downloaded,
* or not a whisper model). Additive/optional so only the Transcription tab renders it.
*/
coreMLStatus?: 'ready' | 'downloading' | 'unavailable';
failedState?: {
errorMessage: string;
bytesDownloaded: number;
Expand Down Expand Up @@ -186,6 +193,7 @@ export const ModelCard: React.FC<ModelCardProps> = ({
isTrending,
recommended,
supportsAcceleration,
coreMLStatus,
failedState,
}) => {
const styles = useThemedStyles(createStyles);
Expand Down Expand Up @@ -234,6 +242,7 @@ export const ModelCard: React.FC<ModelCardProps> = ({
isTrending={isTrending}
recommended={recommended}
supportsAcceleration={supportsAcceleration}
coreMLStatus={coreMLStatus}
/>
) : (
<StandardModelCardContent
Expand All @@ -243,6 +252,7 @@ export const ModelCard: React.FC<ModelCardProps> = ({
isActive={isActive}
recommended={recommended}
supportsAcceleration={supportsAcceleration}
coreMLStatus={coreMLStatus}
/>
)}

Expand Down
Loading