diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..0a216ba9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Run tests +on: + push: + branches: [develop, main] + pull_request: + branches: [develop, main] +jobs: + test: + name: Run tests and collect coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bunx jest --coverage + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 05df070b..96fc4d57 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ credentials/ # The following patterns were generated by expo-cli expo-env.d.ts -# @end expo-cli \ No newline at end of file +# @end expo-cli + +coverage/**/* \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index b251cad6..124ce692 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -91,7 +91,7 @@ android { applicationId 'app.neuland' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 275 + versionCode 279 versionName "0.11.2" } signingConfigs { diff --git a/app.config.json b/app.config.json index 91873b49..f9cb06f9 100644 --- a/app.config.json +++ b/app.config.json @@ -36,7 +36,7 @@ "android": { "package": "app.neuland", "userInterfaceStyle": "automatic", - "versionCode": 275 + "versionCode": 279 }, "sdkVersion": "52.0.0", "experiments": { diff --git a/bun.lockb b/bun.lockb index e40b80be..0992dc5f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4b74eeb9..24fd8958 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,11 @@ "licences": "npm-license-crawler -onlyDirectDependencies -json src/data/licenses.json --exclude docs/", "prepare": "husky", "codegen": "graphql-codegen --config codegen.yml", - "changelog": "git cliff --output CHANGELOG.md" + "changelog": "git cliff --output CHANGELOG.md", + "test": "jest --watch --coverage=false --changedSince=origin/main", + "testDebug": "jest -o --watch --coverage=false", + "testFinal": "jest", + "updateSnapshots": "jest -u --coverage=false" }, "dependencies": { "@aptabase/react-native": "^0.3.10", @@ -125,7 +129,8 @@ "@types/bun": "latest", "@types/color": "^3.0.6", "@types/geojson": "^7946.0.15", - "@types/node": "^22.10.6", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.7", "@types/prop-types": "^15.7.14", "@types/react": "~18.3.18", "@types/sanitize-html": "^2.13.0", @@ -149,7 +154,9 @@ "expo-dev-client": "~5.0.9", "graphql-codegen-typescript-operation-types": "^2.0.1", "husky": "^9.1.7", - "lint-staged": "^15.3.0", + "jest": "~29.7.0", + "jest-expo": "~52.0.3", + "lint-staged": "^15.4.0", "prettier": "3.4.2", "prop-types": "^15.8.1", "typescript": "^5.7.3" @@ -166,5 +173,21 @@ "@th3rdwave/react-navigation-bottom-sheet@0.3.2": "patches/@th3rdwave%2Freact-navigation-bottom-sheet@0.3.2.patch", "@aptabase/react-native@0.3.10": "patches/@aptabase%2Freact-native@0.3.10.patch", "react-native-drag-sort@2.4.4": "patches/react-native-drag-sort@2.4.4.patch" - } + }, + "jest": { + "preset": "jest-expo", + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" + ], + "collectCoverage": true, + "collectCoverageFrom": [ + "**/*.{ts,tsx,js,jsx}", + "!**/coverage/**", + "!**/node_modules/**", + "!**/babel.config.js", + "!**/expo-env.d.ts", + "!**/.expo/**" + ] +} + } diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 9c4b054c..f01768a0 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -49,7 +49,9 @@ export const resources = { export const defaultNS = 'en' export type LanguageKey = keyof typeof resources -const languageCode = getLocales()[0].languageCode ?? '' +const locales = getLocales() +const languageCode = + (locales && locales.length > 0 ? locales[0].languageCode : '') ?? '' const fallbackLanguage = defaultNS const language = Object.keys(resources).includes(languageCode) ? languageCode diff --git a/src/types/utils.ts b/src/types/utils.ts index 013982d0..4b45f197 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -100,7 +100,7 @@ export interface ExamTimetableEntry extends Exam { export interface TimetableSections { title: Date - data: TimetableEntry[] | ExamEntry[] + data: (TimetableEntry | ExamEntry)[] } export interface CalendarEvent { diff --git a/src/utils/__tests__/timetable-utils-test.ts b/src/utils/__tests__/timetable-utils-test.ts new file mode 100644 index 00000000..eda39fb5 --- /dev/null +++ b/src/utils/__tests__/timetable-utils-test.ts @@ -0,0 +1,182 @@ +import { Exam, FriendlyTimetableEntry, TimetableSections } from '@/types/utils' +import moment from 'moment' + +import { + generateKey, + getGroupedTimetable, + isValidRoom, +} from '../timetable-utils' + +describe('getGroupedTimetable', () => { + it('should correctly group and sort timetable entries and exams by date', () => { + const timetable: FriendlyTimetableEntry[] = [ + { + date: new Date('2023-10-01T10:00:00Z'), + startDate: new Date('2023-10-01T10:00:00Z'), + endDate: new Date('2023-10-01T11:00:00Z'), + name: 'Lecture 1', + shortName: 'Lec 1', + rooms: ['Room 1'], + lecturer: 'Dr. Smith', + course: 'Course 1', + studyGroup: 'Group 1', + sws: '2', + ects: '3', + goal: 'Goal 1', + contents: 'Contents 1', + literature: 'Literature 1', + }, + { + date: new Date('2023-10-02T12:00:00Z'), + startDate: new Date('2023-10-02T12:00:00Z'), + endDate: new Date('2023-10-02T13:00:00Z'), + name: 'Lecture 2', + shortName: 'Lec 2', + rooms: ['Room 2'], + lecturer: 'Dr. Johnson', + course: 'Course 2', + studyGroup: 'Group 2', + sws: '2', + ects: '3', + goal: 'Goal 2', + contents: 'Contents 2', + literature: 'Literature 2', + }, + ] + + const exams: Exam[] = [ + { + date: new Date('2023-10-01T08:00:00Z'), + type: 'LN - schriftliche Prüfung, 90 Minuten', + name: 'Exam 1', + rooms: 'Room 3', + seat: 'Seat 1', + notes: 'Notes 1', + examiners: ['Examiner 1'], + enrollment: new Date('2023-09-01T08:00:00Z'), + aids: ['Aid 1'], + }, + { + date: new Date('2023-10-02T09:00:00Z'), + type: 'SP - schrP90 - schriftliche Prüfung, 90 Minuten', + name: 'Exam 2', + rooms: 'Room 4', + seat: 'Seat 2', + notes: 'Notes 2', + examiners: ['Examiner 2'], + enrollment: new Date('2023-09-02T09:00:00Z'), + aids: ['Aid 2'], + }, + ] + + const expectedOutput: TimetableSections[] = [ + { + title: new Date('2023-10-01'), + data: [ + { + date: new Date('2023-10-01T08:00:00Z'), + type: 'LN - schriftliche Prüfung, 90 Minuten', + name: 'Exam 1', + rooms: 'Room 3', + seat: 'Seat 1', + notes: 'Notes 1', + examiners: ['Examiner 1'], + enrollment: new Date('2023-09-01T08:00:00Z'), + aids: ['Aid 1'], + endDate: moment('2023-10-01T08:00:00Z') + .add(90, 'minutes') + .toDate(), + eventType: 'exam', + }, + { + date: new Date('2023-10-01T10:00:00Z'), + startDate: new Date('2023-10-01T10:00:00Z'), + endDate: new Date('2023-10-01T11:00:00Z'), + name: 'Lecture 1', + shortName: 'Lec 1', + rooms: ['Room 1'], + lecturer: 'Dr. Smith', + course: 'Course 1', + studyGroup: 'Group 1', + sws: '2', + ects: '3', + goal: 'Goal 1', + contents: 'Contents 1', + literature: 'Literature 1', + eventType: 'timetable', + }, + ], + }, + { + title: new Date('2023-10-02'), + data: [ + { + date: new Date('2023-10-02T09:00:00Z'), + type: 'SP - schrP90 - schriftliche Prüfung, 90 Minuten', + name: 'Exam 2', + rooms: 'Room 4', + seat: 'Seat 2', + notes: 'Notes 2', + examiners: ['Examiner 2'], + enrollment: new Date('2023-09-02T09:00:00Z'), + aids: ['Aid 2'], + endDate: moment('2023-10-02T09:00:00Z') + .add(90, 'minutes') + .toDate(), + eventType: 'exam', + }, + { + date: new Date('2023-10-02T12:00:00Z'), + startDate: new Date('2023-10-02T12:00:00Z'), + endDate: new Date('2023-10-02T13:00:00Z'), + name: 'Lecture 2', + shortName: 'Lec 2', + rooms: ['Room 2'], + lecturer: 'Dr. Johnson', + course: 'Course 2', + studyGroup: 'Group 2', + sws: '2', + ects: '3', + goal: 'Goal 2', + contents: 'Contents 2', + literature: 'Literature 2', + eventType: 'timetable', + }, + ], + }, + ] + + const result = getGroupedTimetable(timetable, exams) + expect(result).toEqual(expectedOutput) + }) +}) + +describe('generateKey', () => { + it('should generate a unique key for a lecture', () => { + const lectureName = 'Lecture 1' + const startDate = new Date('2023-10-01T10:00:00Z') + const room = 'Room 1' + const expectedKey = 'Lecture 1-1696154400000-Room 1' + const result = generateKey(lectureName, startDate, room) + expect(result).toBe(expectedKey) + }) +}) + +describe('isValidRoom', () => { + it('should return true for valid room strings', () => { + expect(isValidRoom('A101')).toBe(true) + expect(isValidRoom('B202')).toBe(true) + expect(isValidRoom('C303')).toBe(true) + expect(isValidRoom('AU101')).toBe(true) + expect(isValidRoom('BU202')).toBe(true) + expect(isValidRoom('A32')).toBe(true) + }) + + it('should return false for invalid room strings', () => { + expect(isValidRoom('101')).toBe(false) + expect(isValidRoom('Online')).toBe(false) + expect(isValidRoom('AB1234')).toBe(false) + expect(isValidRoom('A1U01')).toBe(false) + expect(isValidRoom('')).toBe(false) + }) +}) diff --git a/src/utils/timetable-utils.ts b/src/utils/timetable-utils.ts index bd48cc27..cd7eed38 100644 --- a/src/utils/timetable-utils.ts +++ b/src/utils/timetable-utils.ts @@ -1,4 +1,3 @@ -import API from '@/api/authenticated-api' import { type CalendarEvent, type Exam, @@ -7,6 +6,7 @@ import { } from '@/types/utils' import moment from 'moment' +import API from '../api/authenticated-api' import { combineDateTime } from './date-utils' /**