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
2 changes: 1 addition & 1 deletion .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
cache: npm

- name: Install dependencies
run: npm install
run: npm install --no-audit --no-fund

- name: Build web bundle with stats
run: npx expo export --platform web --output-dir ./dist --stats-output ./dist/stats.json
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
"@react-navigation/native": "^7.1.8",
"@react-navigation/native-stack": "^7.3.16",
"@sentry/react-native": "~7.2.0",
"@testing-library/react-hooks": "^8.0.1",
"axios": "^1.7.9",
"axios-mock-adapter": "^2.1.0",
"clsx": "^2.1.1",
Expand Down Expand Up @@ -135,7 +134,7 @@
"@types/babel__generator": "^7.27.0",
"@types/babel__template": "^7.4.4",
"@types/babel__traverse": "^7.28.0",
"@types/jest": "29.5.14",
"@types/jest": "^29.5.14",
"@types/node": "^25.0.10",
"@types/react": "~19.1.0",
"ansi-styles": "^5.2.0",
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fonttools>=4.57.0
12 changes: 6 additions & 6 deletions src/__tests__/issues/fixes_620_621_622.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ jest.mock('../../utils/imageOptimization', () => ({
dpr: 1,
})),
}));
import { renderHook } from '@testing-library/react-native';
import React from 'react';
import { Animated } from 'react-native';
import { clearCache, getCache, invalidateByPattern, setCache } from '../../services/api/cache';

jest.mock('../../utils/logger', () => ({
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() },
}));

import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { Animated } from 'react-native';

describe('#621 CachedImage — Animated.Value reference stability', () => {
it('opacity ref is identical across multiple renders (Object.is)', () => {
// Simulate what CachedImageComponent does: useRef(new Animated.Value(0)).current
Expand Down Expand Up @@ -64,12 +65,11 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
jest.mock('../../services/mobileAnalytics', () => ({
mobileAnalyticsService: { trackEvent: jest.fn() },
}));

jest.mock('../../utils/trackingEvents', () => ({
AnalyticsEvent: { PERFORMANCE_METRIC: 'performance_metric' },
}));

import { setCache, getCache, invalidateByPattern, clearCache } from '../../services/api/cache';

describe('#620 invalidateByPattern', () => {
beforeEach(() => clearCache());

Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/keyboard-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
* - useFocusRestore: focus restoration on close
*/

import { renderHook, act } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react-native';
import { Platform } from 'react-native';

import { useKeyboardNavigation, useInteractiveKeyProps } from '../hooks/useKeyboardNavigation';
import { useInteractiveKeyProps, useKeyboardNavigation } from '../hooks/useKeyboardNavigation';

// Force web platform for keyboard tests
const originalOS = Platform.OS;
Expand Down
85 changes: 81 additions & 4 deletions src/__tests__/store/courseProgressStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
useCourseProgressStore,
completionInProgress,
InvalidLessonProgressError,
_completionTimers,
completionInProgress,
isValidLessonProgress,
useCourseProgressStore,
} from '../../store/courseProgressStore';

import type { CourseProgress } from '../../types/course';
import type { CourseProgress, LessonProgress } from '../../types/course';

const baseCourseProgress = (courseId: string): CourseProgress => ({
courseId,
Expand All @@ -23,6 +24,10 @@ describe('courseProgressStore — markLessonComplete / isCourseComplete', () =>
useCourseProgressStore.setState({ progressMap: {} });
});

// ---------------------------------------------------------------------------
// Existing Progress Calculation Tests
// ---------------------------------------------------------------------------

it('does not mark complete at 1/3 lessons', () => {
const courseId = 'c1';
useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId));
Expand Down Expand Up @@ -72,6 +77,78 @@ describe('courseProgressStore — markLessonComplete / isCourseComplete', () =>
it('isCourseComplete returns false for unknown course', () => {
expect(useCourseProgressStore.getState().isCourseComplete('unknown', 5)).toBe(false);
});

// ---------------------------------------------------------------------------
// Issue #808 Validation & Type Guard Tests
// ---------------------------------------------------------------------------

describe('isValidLessonProgress type guard', () => {
it('returns true for complete and valid LessonProgress data', () => {
const validData: Partial<LessonProgress> = {
lessonId: 'l1',
completed: true,
lastPosition: 120,
timeSpent: 300,
completedAt: new Date().toISOString(),
};
expect(isValidLessonProgress(validData)).toBe(true);
});

it('returns false when required fields are empty, missing, or wrong type', () => {
expect(isValidLessonProgress({ lessonId: '' })).toBe(false);
expect(isValidLessonProgress({ lessonId: 'l1', completed: 'yes' as any })).toBe(false);
expect(isValidLessonProgress({ lessonId: 'l1', timeSpent: 'none' as any })).toBe(false);
expect(isValidLessonProgress(null as any)).toBe(false);
});
});

describe('markLessonComplete — lessonData validation', () => {
it('accepts and applies valid custom lessonData overrides', () => {
const courseId = 'c_custom';
useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId));

useCourseProgressStore.getState().markLessonComplete(courseId, 'l1', 1, {
timeSpent: 450,
lastPosition: 30,
});

const updatedLesson = useCourseProgressStore.getState().getCourseProgress(courseId)?.lessons[
'l1'
];
expect(updatedLesson?.timeSpent).toBe(450);
expect(updatedLesson?.lastPosition).toBe(30);
expect(updatedLesson?.completed).toBe(true);
});

it('throws InvalidLessonProgressError when invalid lessonData overrides required fields', () => {
const courseId = 'c_invalid';
useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId));

expect(() => {
useCourseProgressStore.getState().markLessonComplete(courseId, 'l1', 1, {
lessonId: '', // Invalid empty string override
});
}).toThrow(InvalidLessonProgressError);
});

it('does not mutate store state when an invalid update is rejected', () => {
const courseId = 'c_rollback';
useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId));

const initialState = useCourseProgressStore.getState().getCourseProgress(courseId);

try {
useCourseProgressStore.getState().markLessonComplete(courseId, 'l1', 1, {
lessonId: ' ', // Invalid whitespace-only ID
});
} catch (err) {
expect(err).toBeInstanceOf(InvalidLessonProgressError);
}

const currentState = useCourseProgressStore.getState().getCourseProgress(courseId);
expect(currentState?.lessons).toEqual(initialState?.lessons);
});
});
});

describe('courseProgressStore — markLessonComplete deduplication', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { describe, it, expect } from "@jest/globals";
import PerformanceSearchDashboard from "../PerformanceSearchDashboard";
import { ISplitTransactionRecord } from "../../../types/search";

Expand Down
3 changes: 2 additions & 1 deletion src/components/mobile/MobileQuizManager/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

import { useAnalytics } from '../../../hooks/useAnalytics';
Expand Down Expand Up @@ -268,6 +268,7 @@ export default function MobileQuizManager({
</ScrollView>
</SafeAreaView>
);
}

// Render resume prompt modal
if (showResumePrompt) {
Expand Down
45 changes: 36 additions & 9 deletions src/store/courseProgressStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ import type { Lesson } from '../types/course';
const LESSON_WINDOW_RADIUS = 2;
const LESSON_PAGE_SIZE = LESSON_WINDOW_RADIUS * 2 + 1; // 5 lessons per fetch

export class InvalidLessonProgressError extends Error {
constructor(message = 'Invalid lesson progress data: missing or invalid required fields.') {
super(message);
this.name = 'InvalidLessonProgressError';
}
}

export function isValidLessonProgress(data: Partial<LessonProgress>): data is LessonProgress {
return (
typeof data === 'object' &&
data !== null &&
typeof data.lessonId === 'string' &&
data.lessonId.trim().length > 0 &&
typeof data.completed === 'boolean' &&
typeof data.lastPosition === 'number' &&
typeof data.timeSpent === 'number' &&
typeof data.completedAt === 'string'
);
}

interface CourseProgressState {
// keyed by courseId
progressMap: Record<string, CourseProgress>;
Expand Down Expand Up @@ -88,6 +108,22 @@ export const useCourseProgressStore = create<CourseProgressState>()(
getCourseProgress: courseId => get().progressMap[courseId] ?? null,

markLessonComplete: (courseId, lessonId, totalLessons, lessonData) => {
const candidateProgress: Partial<LessonProgress> = {
lessonId,
completed: true,
lastPosition: 0,
timeSpent: 0,
completedAt: new Date().toISOString(),
...lessonData,
};

if (!isValidLessonProgress(candidateProgress)) {
throw new InvalidLessonProgressError(
'Failed to update course progress: lessonData is missing required fields.'
);
}

const lessonProgress: LessonProgress = candidateProgress;
// ── Deduplication guard ───────────────────────────────────────────
// A 500 ms window prevents duplicate completion records when multiple
// triggers fire for the same lesson (e.g., video-end + manual skip).
Expand All @@ -113,15 +149,6 @@ export const useCourseProgressStore = create<CourseProgressState>()(
const existing = s.progressMap[courseId];
if (!existing) return s;

const lessonProgress: LessonProgress = {
lessonId,
completed: true,
lastPosition: 0,
timeSpent: 0,
completedAt: new Date().toISOString(),
...lessonData,
};

const updatedLessons = { ...existing.lessons, [lessonId]: lessonProgress };
const completedLessons = Object.values(updatedLessons).filter(l => l.completed).length;

Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@
"types": ["jest", "node"]
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"],
"exclude": ["node_modules", "tests", "**/__tests__/**", "**/_tests_/**"]
}
"exclude": ["node_modules"]
}
Loading