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
3 changes: 3 additions & 0 deletions src/data/modules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Module } from './types';

// TODO: este arquivo agora é apenas FALLBACK offline. O conteúdo oficial vem do
// Firestore (ver src/services/models/courseContent.ts). A aposentadoria final
// deste arquivo acontece após o seed no Firebase real (rodar seedLessons.ts).
export const MODULES: Module[] = [
{
id: 'mod-1',
Expand Down
28 changes: 25 additions & 3 deletions src/screens/app/ModuleDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
import Ionicons from 'react-native-vector-icons/Ionicons';
import { MODULES } from '../../data/modules';
import { Colors, Spacing, FontSize, BorderRadius } from '../../theme';
import type { AppStackScreenProps } from '../../@types/navigation';
import { auth } from '../../services/firebase';
import { getModuleLessonProgress } from '../../services/models/userProgress';
import { getModuleDetail, type ModuleDetail } from '../../services/models/courseContent';

export default function ModuleDetailScreen() {
const route = useRoute<AppStackScreenProps<'ModuleDetail'>['route']>();
const navigation = useNavigation();
const { moduleId } = route.params;
const [lessonStatus, setLessonStatus] = useState<Record<string, { completed: boolean; locked: boolean }>>({});
const [mod, setMod] = useState<ModuleDetail | null | undefined>(undefined);

const mod = MODULES.find((m) => m.id === moduleId);
useEffect(() => {
let active = true;
getModuleDetail(moduleId)
.then((detail) => {
if (active) setMod(detail ?? null);
})
.catch((error) => {
console.error(error);
if (active) setMod(null);
});
return () => {
active = false;
};
}, [moduleId]);

useFocusEffect(
useCallback(() => {
Expand All @@ -38,6 +52,14 @@
}, [moduleId])
);

if (mod === undefined) {
return (
<View style={styles.container}>
<Text style={styles.description}>Carregando módulo...</Text>
</View>
);
}

if (!mod) {
return (
<View style={styles.container}>
Expand Down Expand Up @@ -66,7 +88,7 @@
style={[styles.lessonCard, lessonStatus[item.id]?.locked && styles.lessonCardLocked]}
disabled={lessonStatus[item.id]?.locked}
onPress={() =>
navigation.navigate('SignTeaching', { lessonId: item.id, moduleId: mod.id })

Check failure on line 91 in src/screens/app/ModuleDetailScreen.tsx

View workflow job for this annotation

GitHub Actions / Lint, Tipos e Testes

Argument of type '[string, { lessonId: string; moduleId: string; }]' is not assignable to parameter of type 'never'.
}
>
<View style={styles.lessonNumber}>
Expand Down
33 changes: 28 additions & 5 deletions src/screens/app/SignTeachingScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,42 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import Ionicons from 'react-native-vector-icons/Ionicons';
import { MODULES } from '../../data/modules';
import { Colors, Spacing, FontSize, BorderRadius } from '../../theme';
import type { AppStackScreenProps } from '../../@types/navigation';
import { getLessonContent, type LessonContent } from '../../services/models/courseContent';

export default function SignTeachingScreen() {
const route = useRoute<AppStackScreenProps<'SignTeaching'>['route']>();
const navigation = useNavigation();
const { lessonId, moduleId } = route.params;
const [isPlaying, setIsPlaying] = useState(false);
const [lesson, setLesson] = useState<LessonContent | null | undefined>(undefined);

const mod = MODULES.find((m) => m.id === moduleId);
const lesson = mod?.lessons.find((l) => l.id === lessonId);
useEffect(() => {
let active = true;
getLessonContent(lessonId, moduleId)
.then((content) => {
if (active) setLesson(content ?? null);
})
.catch((error) => {
console.error(error);
if (active) setLesson(null);
});
return () => {
active = false;
};
}, [lessonId, moduleId]);

if (lesson === undefined) {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.descriptionText}>Carregando lição...</Text>
</View>
</View>
);
}

if (!lesson) {
return (
Expand All @@ -26,10 +49,10 @@
const currentVocab = lesson.vocabulary[0];

const handleContinue = () => {
const questions = lesson.activities.flatMap((activity) => activity.questions);
const questions = lesson.questions;

if (questions.length === 0) {
navigation.navigate('LessonComplete', {

Check failure on line 55 in src/screens/app/SignTeachingScreen.tsx

View workflow job for this annotation

GitHub Actions / Lint, Tipos e Testes

Argument of type '[string, { lessonId: string; moduleId: string; score: number; xpEarned: number; }]' is not assignable to parameter of type 'never'.
lessonId,
moduleId,
score: 85,
Expand Down
18 changes: 18 additions & 0 deletions src/screens/app/__tests__/ModuleDetailScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react-native';
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
import { auth } from '../../../services/firebase';
import { getModuleLessonProgress } from '../../../services/models/userProgress';
import { getModuleDetail } from '../../../services/models/courseContent';
import ModuleDetailScreen from '../ModuleDetailScreen';

jest.mock('react-native-vector-icons/Ionicons', () => 'Ionicons');
Expand All @@ -23,11 +24,16 @@ jest.mock('../../../services/models/userProgress', () => ({
getModuleLessonProgress: jest.fn(),
}));

jest.mock('../../../services/models/courseContent', () => ({
getModuleDetail: jest.fn(),
}));

describe('ModuleDetailScreen', () => {
const mockedUseFocusEffect = useFocusEffect as jest.MockedFunction<typeof useFocusEffect>;
const mockedUseNavigation = useNavigation as jest.MockedFunction<typeof useNavigation>;
const mockedUseRoute = useRoute as jest.MockedFunction<typeof useRoute>;
const mockedGetModuleLessonProgress = getModuleLessonProgress as jest.MockedFunction<typeof getModuleLessonProgress>;
const mockedGetModuleDetail = getModuleDetail as jest.MockedFunction<typeof getModuleDetail>;

beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
Expand All @@ -44,6 +50,18 @@ describe('ModuleDetailScreen', () => {
{ lessonId: 'les-1-1', completed: true, locked: false },
{ lessonId: 'les-1-2', completed: false, locked: true },
] as never);
mockedGetModuleDetail.mockResolvedValue({
id: 'mod-1',
title: 'Saudações',
description: 'Aprenda os sinais básicos de saudações em Libras.',
objective: 'Comunicar cumprimentos e apresentações simples.',
iconName: 'hand-wave',
order: 1,
lessons: [
{ id: 'les-1-1', title: 'Olá e Tchau', description: 'Sinais para cumprimentar.', order: 1, xpReward: 50 },
{ id: 'les-1-2', title: 'Apresentações Pessoais', description: 'Aprenda a se apresentar.', order: 2, xpReward: 60 },
],
} as never);
auth.currentUser = { uid: 'user-123' } as never;
});

Expand Down
40 changes: 36 additions & 4 deletions src/screens/app/__tests__/SignTeachingScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react-native';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { getLessonContent } from '../../../services/models/courseContent';
import SignTeachingScreen from '../SignTeachingScreen';

jest.mock('react-native-vector-icons/Ionicons', () => 'Ionicons');
Expand All @@ -10,10 +11,15 @@ jest.mock('@react-navigation/native', () => ({
useRoute: jest.fn(),
}));

jest.mock('../../../services/models/courseContent', () => ({
getLessonContent: jest.fn(),
}));

describe('SignTeachingScreen', () => {
const mockNavigate = jest.fn();
const mockedUseNavigation = useNavigation as jest.MockedFunction<typeof useNavigation>;
const mockedUseRoute = useRoute as jest.MockedFunction<typeof useRoute>;
const mockedGetLessonContent = getLessonContent as jest.MockedFunction<typeof getLessonContent>;

beforeEach(() => {
mockNavigate.mockReset();
Expand All @@ -24,21 +30,47 @@ describe('SignTeachingScreen', () => {
moduleId: 'mod-1',
},
} as never);
mockedGetLessonContent.mockResolvedValue({
id: 'les-1-1',
title: 'Olá e Tchau',
description: 'Sinais para cumprimentar.',
order: 1,
xpReward: 50,
vocabulary: [
{ id: 'v-1-1-1', word: 'Olá', translation: 'Sinal de saudação com a mão aberta' },
{ id: 'v-1-1-2', word: 'Tchau', translation: 'Sinal de despedida acenando a mão' },
],
expressions: ['Olá, tudo bem?'],
questions: [
{
id: 'q-1-1-1',
type: 'multiple_choice',
prompt: 'Qual é o sinal de "Olá" em Libras?',
options: ['Mão aberta acenando', 'Punho fechado'],
correctAnswer: 'Mão aberta acenando',
},
],
} as never);
});

it('renders the lesson introduction interface', () => {
it('renders the lesson introduction interface', async () => {
render(<SignTeachingScreen />);

expect(screen.getAllByText('Olá').length).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.getAllByText('Olá').length).toBeGreaterThan(0);
});
expect(screen.getByText('Como fazer o sinal')).toBeTruthy();
expect(screen.getAllByText('Sinal de saudação com a mão aberta').length).toBeGreaterThan(0);
expect(screen.getByText('Vocabulário da lição')).toBeTruthy();
expect(screen.getByText('Continuar')).toBeTruthy();
});

it('navigates to Quiz with lesson questions when continuing', () => {
it('navigates to Quiz with lesson questions when continuing', async () => {
render(<SignTeachingScreen />);

await waitFor(() => {
expect(screen.getByText('Continuar')).toBeTruthy();
});
fireEvent.press(screen.getByText('Continuar'));

expect(mockNavigate).toHaveBeenCalledWith('Quiz', expect.objectContaining({
Expand Down
18 changes: 18 additions & 0 deletions src/scripts/seedLessons.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/**
* Script para popular o Firebase com dados das lições.
* Executar com: npx ts-node src/scripts/seedLessons.ts
*
* Idempotente: faz upsert por id (createCourse/createLesson usam setDoc e
* addModule faz upsert por moduleId). Reexecutar não duplica nem deleta nada.
*/
import { MODULES } from '../data/modules';
import { createCourse, addModule, CourseLessonRef, CourseModule } from '../services/models/courses';
Expand Down Expand Up @@ -42,6 +45,18 @@ async function seed() {
title: lesson.title,
courseId: COURSE_ID,
moduleId: mod.id,
description: lesson.description,
order: lesson.order,
xpReward: lesson.xpReward,
vocabulary: lesson.vocabulary.map((v) => ({
id: v.id,
word: v.word,
translation: v.translation,
videoUrl: v.videoUrl,
imageUrl: v.imageUrl,
})),
expressions: lesson.expressions,
grammar: lesson.grammar,
questions,
};

Expand All @@ -58,6 +73,9 @@ async function seed() {
moduleId: mod.id,
title: mod.title,
order: mod.order,
description: mod.description,
objective: mod.objective,
iconName: mod.iconName,
lessons: lessonRefs,
};
await addModule(COURSE_ID, courseModule);
Expand Down
90 changes: 90 additions & 0 deletions src/services/__tests__/services/models/courseContent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
jest.mock('../../../models/courses', () => ({
getCourseById: jest.fn(),
}));

jest.mock('../../../models/lessons', () => ({
getLessonById: jest.fn(),
}));

import { getCourseById } from '../../../models/courses';
import { getLessonById } from '../../../models/lessons';
import { getLessonContent, getModuleDetail } from '../../../models/courseContent';

describe('courseContent (Firestore-first com fallback)', () => {
const mockedGetCourseById = getCourseById as jest.MockedFunction<typeof getCourseById>;
const mockedGetLessonById = getLessonById as jest.MockedFunction<typeof getLessonById>;

beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
mockedGetCourseById.mockReset();
mockedGetLessonById.mockReset();
});

it('lê o módulo do Firestore quando disponível', async () => {
mockedGetCourseById.mockResolvedValue({
id: 'libras-basico',
title: 'Libras Básico',
modules: [
{
moduleId: 'mod-1',
title: 'Saudações Remoto',
order: 1,
description: 'Descrição remota',
objective: 'Objetivo remoto',
iconName: 'hand-wave',
lessons: [{ lessonId: 'les-1-1', title: 'Olá e Tchau', type: 'standard', order: 1 }],
},
],
} as never);

const detail = await getModuleDetail('mod-1');

expect(detail?.title).toBe('Saudações Remoto');
expect(detail?.description).toBe('Descrição remota');
expect(detail?.lessons).toHaveLength(1);
});

it('cai no fallback de modules.ts quando o Firestore vem vazio', async () => {
mockedGetCourseById.mockResolvedValue({ id: 'libras-basico', title: 'Libras Básico', modules: [] } as never);

const detail = await getModuleDetail('mod-1');

expect(detail?.id).toBe('mod-1');
expect(detail?.title).toBe('Saudações');
});

it('cai no fallback quando o Firestore lança erro', async () => {
mockedGetCourseById.mockRejectedValue(new Error('offline'));

const detail = await getModuleDetail('mod-1');

expect(detail?.id).toBe('mod-1');
expect(detail?.title).toBe('Saudações');
});

it('lê a lição do Firestore quando há vocabulário', async () => {
mockedGetLessonById.mockResolvedValue({
id: 'les-1-1',
title: 'Olá e Tchau',
courseId: 'libras-basico',
moduleId: 'mod-1',
xpReward: 50,
vocabulary: [{ id: 'v-1', word: 'Olá Remoto', translation: 'tradução remota' }],
expressions: ['Olá!'],
questions: [],
} as never);

const lesson = await getLessonContent('les-1-1', 'mod-1');

expect(lesson?.vocabulary[0].word).toBe('Olá Remoto');
});

it('cai no fallback de modules.ts quando a lição remota vem sem vocabulário', async () => {
mockedGetLessonById.mockResolvedValue(null);

const lesson = await getLessonContent('les-1-1', 'mod-1');

expect(lesson?.id).toBe('les-1-1');
expect(lesson?.vocabulary[0].word).toBe('Olá');
});
});
Loading
Loading