Skip to content
Open
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
69 changes: 41 additions & 28 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { Stack, useRouter, usePathname, useSegments } from "expo-router";
import React, { useCallback, useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import "../global.css"; // NativeWind CSS
import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from "../src/components";
import { Stack, useRouter, usePathname, useSegments } from 'expo-router';
import React, { useCallback, useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

import 'react-native-reanimated';
import '../global.css'; // NativeWind CSS
import {
AnalyticsProvider,
ErrorBoundary,
OfflineIndicatorProvider,
SearchIndexProvider,
} from '../src/components';
import { useAnalytics } from '../src/hooks';
import { useDeepLink } from '../src/hooks/useDeepLink';
import { getPathFromDeepLink } from '../src/utils/linkParser';

// Component to handle auto screen tracking
function ScreenTracker() {
const ScreenTracker = () => {
const pathname = usePathname();
const segments = useSegments();
const { trackScreen } = useAnalytics();
Expand All @@ -22,17 +28,20 @@ function ScreenTracker() {
}, [pathname, segments, trackScreen]);

return null;
}
};

export default function RootLayout() {
const RootLayout = () => {
const router = useRouter();

const handleDeepLink = useCallback((deepLink) => {
const path = getPathFromDeepLink(deepLink);
if (path) {
router.replace(path);
}
}, [router]);
const handleDeepLink = useCallback(
deepLink => {
const path = getPathFromDeepLink(deepLink);
if (path) {
router.replace(path);
}
},
[router]
);

useDeepLink(handleDeepLink);

Expand All @@ -41,20 +50,24 @@ export default function RootLayout() {
<AnalyticsProvider>
<ScreenTracker />
<OfflineIndicatorProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="course-viewer" options={{ headerShown: false }} />
<Stack.Screen name="profile/[userId]" options={{ headerShown: false }} />
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="qr-scanner" options={{ headerShown: false }} />
<Stack.Screen name="quiz" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</GestureHandlerRootView>
<SearchIndexProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="course-viewer" options={{ headerShown: false }} />
<Stack.Screen name="profile/[userId]" options={{ headerShown: false }} />
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="qr-scanner" options={{ headerShown: false }} />
<Stack.Screen name="quiz" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</GestureHandlerRootView>
</SearchIndexProvider>
</OfflineIndicatorProvider>
</AnalyticsProvider>
</ErrorBoundary>
);
}
};

export default RootLayout;
195 changes: 195 additions & 0 deletions src/__tests__/services/searchIndex/SearchIndex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { SearchIndex } from '../../../services/searchIndex/SearchIndex';
import { IndexableDoc } from '../../../services/searchIndex/types';

function doc(
id: string,
title: string,
body = '',
category = '',
level: 'beginner' | 'intermediate' | 'advanced' = 'beginner'
): IndexableDoc {
return { id, type: 'course', fields: { title, body, category, level } };
}

describe('SearchIndex', () => {
let index: SearchIndex;

beforeEach(() => {
index = new SearchIndex();
});

describe('build / size / clear', () => {
it('reports size after build', () => {
index.build([doc('1', 'A'), doc('2', 'B')]);
expect(index.size()).toBe(2);
expect(index.isReady()).toBe(true);
});

it('clear empties the index', () => {
index.build([doc('1', 'A')]);
index.clear();
expect(index.size()).toBe(0);
expect(index.isReady()).toBe(false);
});
});

describe('search', () => {
beforeEach(() => {
index.build([
doc('1', 'React Native Fundamentals', 'Learn mobile development', 'Mobile Development'),
doc('2', 'Advanced JavaScript', 'Closures and prototypes', 'Web Development', 'advanced'),
doc('3', 'Mobile UI Design', 'Design beautiful interfaces', 'Design'),
doc('4', 'React Hooks Deep Dive', 'useState useEffect useMemo', 'Web Development'),
doc('5', 'Native Modules in Expo', 'Bridging native code', 'Mobile Development'),
]);
});

it('returns empty when no token matches', () => {
expect(index.search('nonsensequery')).toEqual([]);
});

it('finds title matches', () => {
const hits = index.search('react');
const ids = hits.map(h => h.id);
expect(ids).toEqual(expect.arrayContaining(['1', '4']));
});

it('intersects multi-token queries (AND)', () => {
const hits = index.search('react native');
expect(hits.map(h => h.id)).toEqual(['1']);
});

it('matches across body and category', () => {
const hits = index.search('design');
expect(hits.map(h => h.id)).toEqual(expect.arrayContaining(['3']));
});

it('supports prefix match on the trailing token', () => {
const hits = index.search('rea');
const ids = hits.map(h => h.id);
expect(ids).toEqual(expect.arrayContaining(['1', '4']));
});

it('ranks title hits above body-only hits', () => {
const fresh = new SearchIndex();
fresh.build([
doc('a', 'Body Only', 'this lesson covers expo deeply', 'Web Development'),
doc('b', 'Expo Deep Dive', 'introductory material', 'Mobile Development'),
]);
const hits = fresh.search('expo');
expect(hits[0].id).toBe('b');
});

it('applies category filters', () => {
const hits = index.search('mobile', { filters: { category: 'Mobile Development' } });
expect(hits.every(h => h.doc.fields.category === 'Mobile Development')).toBe(true);
});

it('applies level filters', () => {
const hits = index.search('javascript', { filters: { level: 'advanced' } });
expect(hits.map(h => h.id)).toEqual(['2']);
});

it('respects the limit option', () => {
const hits = index.search('react', { limit: 1 });
expect(hits).toHaveLength(1);
});

it('returns filtered docs when query is empty', () => {
const hits = index.search('', { filters: { category: 'Design' }, limit: 10 });
expect(hits.map(h => h.id)).toEqual(['3']);
});
});

describe('update / remove', () => {
it('update inserts a new doc', () => {
index.build([doc('1', 'A')]);
index.update(doc('2', 'Beta'));
expect(index.size()).toBe(2);
expect(index.search('beta').map(h => h.id)).toEqual(['2']);
});

it('update replaces an existing doc', () => {
index.build([doc('1', 'Old Title')]);
index.update(doc('1', 'New Title'));
expect(index.search('old')).toEqual([]);
expect(index.search('new').map(h => h.id)).toEqual(['1']);
});

it('remove drops a doc and its postings', () => {
index.build([doc('1', 'Alpha'), doc('2', 'Beta')]);
index.remove('1');
expect(index.size()).toBe(1);
expect(index.search('alpha')).toEqual([]);
});
});

describe('serialize / hydrate', () => {
it('round-trips through a snapshot', () => {
index.build([doc('1', 'Alpha'), doc('2', 'Beta')]);
const snap = index.serialize();
const fresh = new SearchIndex();
fresh.hydrate(snap);
expect(fresh.size()).toBe(2);
expect(fresh.search('alpha').map(h => h.id)).toEqual(['1']);
});

it('rejects snapshots with mismatched version', () => {
index.build([doc('1', 'Alpha')]);
const snap = index.serialize();
const fresh = new SearchIndex();
fresh.hydrate({ ...snap, version: 999 });
expect(fresh.size()).toBe(0);
});
});

describe('performance', () => {
it('searches 500 docs in under 100ms', () => {
const docs: IndexableDoc[] = [];
const categories = ['Mobile Development', 'Web Development', 'Design', 'Data'];
const wordPool = [
'react',
'native',
'expo',
'javascript',
'typescript',
'design',
'mobile',
'web',
'animation',
'navigation',
'hooks',
'modules',
'performance',
'testing',
'analytics',
'data',
'rendering',
];
for (let i = 0; i < 500; i++) {
const w1 = wordPool[i % wordPool.length];
const w2 = wordPool[(i * 3) % wordPool.length];
const w3 = wordPool[(i * 7) % wordPool.length];
docs.push({
id: `c${i}`,
type: 'course',
fields: {
title: `${w1} ${w2} course ${i}`,
body: `An in-depth look at ${w1} and ${w3}, covering practical examples.`,
category: categories[i % categories.length],
level: 'beginner',
},
});
}
index.build(docs);

const start = Date.now();
for (let i = 0; i < 10; i++) {
index.search('react native', { limit: 25 });
}
const elapsed = Date.now() - start;
const perCall = elapsed / 10;
expect(perCall).toBeLessThan(100);
});
});
});
98 changes: 98 additions & 0 deletions src/__tests__/services/searchIndex/persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import AsyncStorage from '@react-native-async-storage/async-storage';

import {
SNAPSHOT_KEY,
clearSnapshot,
loadSnapshot,
saveSnapshot,
} from '../../../services/searchIndex/persistence';
import { INDEX_VERSION, IndexSnapshot } from '../../../services/searchIndex/types';

const setItem = AsyncStorage.setItem as jest.Mock;
const getItem = AsyncStorage.getItem as jest.Mock;
const removeItem = AsyncStorage.removeItem as jest.Mock;

function withInMemoryStore() {
const store = new Map<string, string>();
setItem.mockImplementation((k: string, v: string) => {
store.set(k, v);
return Promise.resolve();
});
getItem.mockImplementation((k: string) => Promise.resolve(store.get(k) ?? null));
removeItem.mockImplementation((k: string) => {
store.delete(k);
return Promise.resolve();
});
return store;
}

function snapshot(docs: IndexSnapshot['docs'] = []): IndexSnapshot {
return {
version: INDEX_VERSION,
hash: 'h1',
builtAt: Date.now(),
docs,
};
}

describe('searchIndex/persistence', () => {
beforeEach(() => {
setItem.mockReset();
getItem.mockReset();
removeItem.mockReset();
});

it('round-trips a snapshot through AsyncStorage', async () => {
withInMemoryStore();
const snap = snapshot([
{ id: '1', type: 'course', fields: { title: 'Alpha' } },
{ id: '2', type: 'course', fields: { title: 'Beta' } },
]);
const ok = await saveSnapshot(snap);
expect(ok).toBe(true);

const loaded = await loadSnapshot();
expect(loaded).not.toBeNull();
expect(loaded?.docs).toHaveLength(2);
expect(loaded?.docs[0].fields.title).toBe('Alpha');
});

it('returns null when no snapshot is stored', async () => {
getItem.mockResolvedValueOnce(null);
const loaded = await loadSnapshot();
expect(loaded).toBeNull();
});

it('returns null when stored payload is corrupt', async () => {
getItem.mockResolvedValueOnce('{not json');
const loaded = await loadSnapshot();
expect(loaded).toBeNull();
});

it('returns null when version is mismatched', async () => {
getItem.mockResolvedValueOnce(
JSON.stringify({ version: 999, hash: 'x', builtAt: 0, docs: [] })
);
const loaded = await loadSnapshot();
expect(loaded).toBeNull();
});

it('skips persistence when payload exceeds size cap', async () => {
withInMemoryStore();
const huge = 'x'.repeat(50_000);
const docs: IndexSnapshot['docs'] = [];
for (let i = 0; i < 10; i++) {
docs.push({ id: `d${i}`, type: 'course', fields: { title: huge, body: huge } });
}
const ok = await saveSnapshot(snapshot(docs));
expect(ok).toBe(false);
expect(setItem).not.toHaveBeenCalled();
});

it('clearSnapshot removes the stored entry', async () => {
const store = withInMemoryStore();
store.set(SNAPSHOT_KEY, '{"version":1}');
await clearSnapshot();
expect(removeItem).toHaveBeenCalledWith(SNAPSHOT_KEY);
});
});
Loading
Loading