diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index 98b84d041..b9390ec91 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -13,6 +13,7 @@ // limitations under the License. import { CoreConstants } from '@/core/constants'; +import { safeNumber, SafeNumber } from '@/core/utils/types'; import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; @@ -88,7 +89,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Last attempt access info. protected moreAttempts = false; // Whether user can create/continue attempts. protected options?: AddonModQuizCombinedReviewOptions; // Combined review options. - protected gradebookData?: { grade?: number; feedback?: string }; // The gradebook grade and feedback. + protected gradebookData?: { grade?: SafeNumber; feedback?: string }; // The gradebook grade and feedback. protected overallStats = false; // Equivalent to overallstats in mod_quiz_view_object in Moodle. protected finishedObserver?: CoreEventObserver; // It will observe attempt finished events. protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted). @@ -633,8 +634,10 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp const data = await AddonModQuiz.getGradeFromGradebook(this.courseId, this.module.id); if (data) { + const grade = data.graderaw ?? (data.grade !== undefined && data.grade !== null ? Number(data.grade) : undefined); + this.gradebookData = { - grade: data.graderaw ?? (data.grade !== undefined && data.grade !== null ? Number(data.grade) : undefined), + grade: safeNumber(grade), feedback: data.feedback, }; } diff --git a/src/addons/mod/quiz/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts index 21b26bab2..7972ac3c5 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.ts +++ b/src/addons/mod/quiz/pages/attempt/attempt.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { isSafeNumber } from '@/core/utils/types'; import { Component, OnInit } from '@angular/core'; import { CoreError } from '@classes/errors/error'; import { IonRefresher } from '@ionic/angular'; @@ -108,7 +109,7 @@ export class AddonModQuizAttemptPage implements OnInit { const grade = Number(this.attempt.rescaledGrade); if (this.quiz.showFeedbackColumn && AddonModQuiz.isAttemptFinished(this.attempt.state) && - options.someoptions.overallfeedback && !isNaN(grade)) { + options.someoptions.overallfeedback && isSafeNumber(grade)) { // Feedback should be displayed, get the feedback for the grade. const response = await AddonModQuiz.getFeedbackForGrade(this.quiz.id, grade, { diff --git a/src/addons/mod/quiz/services/handlers/prefetch.ts b/src/addons/mod/quiz/services/handlers/prefetch.ts index 3457eb277..52b48195d 100644 --- a/src/addons/mod/quiz/services/handlers/prefetch.ts +++ b/src/addons/mod/quiz/services/handlers/prefetch.ts @@ -13,6 +13,7 @@ // limitations under the License. import { CoreConstants } from '@/core/constants'; +import { isSafeNumber } from '@/core/utils/types'; import { Injectable } from '@angular/core'; import { CoreError } from '@classes/errors/error'; @@ -120,11 +121,12 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet } const attemptGrade = AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false); - if (attemptGrade === undefined) { + const attemptGradeNumber = attemptGrade !== undefined && Number(attemptGrade); + if (!isSafeNumber(attemptGradeNumber)) { return; } - const feedback = await AddonModQuiz.getFeedbackForGrade(quiz.id, Number(attemptGrade), { + const feedback = await AddonModQuiz.getFeedbackForGrade(quiz.id, attemptGradeNumber, { cmId: quiz.coursemodule, readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK, siteId, @@ -421,8 +423,9 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet if (AddonModQuiz.isAttemptFinished(attempt.state)) { // Attempt is finished, get feedback and review data. const attemptGrade = AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false); - if (attemptGrade !== undefined) { - promises.push(AddonModQuiz.getFeedbackForGrade(quiz.id, Number(attemptGrade), modOptions)); + const attemptGradeNumber = attemptGrade !== undefined && Number(attemptGrade); + if (isSafeNumber(attemptGradeNumber)) { + promises.push(AddonModQuiz.getFeedbackForGrade(quiz.id, attemptGradeNumber, modOptions)); } // Get the review for each page. diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts index c9693a201..95fefe96c 100644 --- a/src/addons/mod/quiz/services/quiz.ts +++ b/src/addons/mod/quiz/services/quiz.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { SafeNumber } from '@/core/utils/types'; import { Injectable } from '@angular/core'; import { CoreError } from '@classes/errors/error'; @@ -592,7 +593,7 @@ export class AddonModQuizProvider { */ async getFeedbackForGrade( quizId: number, - grade: number, + grade: SafeNumber, options: CoreCourseCommonModWSOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); @@ -2053,7 +2054,7 @@ export type AddonModQuizAttemptWSData = { timemodified?: number; // Last modified time. timemodifiedoffline?: number; // Last modified time via webservices. timecheckstate?: number; // Next time quiz cron should check attempt for state changes. NULL means never check. - sumgrades?: number | null; // Total marks for this attempt. + sumgrades?: SafeNumber | null; // Total marks for this attempt. }; /** @@ -2304,7 +2305,7 @@ export type AddonModQuizGetUserBestGradeWSParams = { */ export type AddonModQuizGetUserBestGradeWSResponse = { hasgrade: boolean; // Whether the user has a grade on the given quiz. - grade?: number; // The grade (only if the user has a grade). + grade?: SafeNumber; // The grade (only if the user has a grade). gradetopass?: number; // @since 3.11. The grade to pass the quiz (only if set). warnings?: CoreWSExternalWarning[]; }; diff --git a/src/core/features/grades/services/grades.ts b/src/core/features/grades/services/grades.ts index a59b1eccd..cb08c31c5 100644 --- a/src/core/features/grades/services/grades.ts +++ b/src/core/features/grades/services/grades.ts @@ -21,6 +21,7 @@ import { CoreLogger } from '@singletons/logger'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreSiteWSPreSets } from '@classes/site'; import { CoreError } from '@classes/errors/error'; +import { SafeNumber } from '@/core/utils/types'; /** * Service to provide grade functionalities. @@ -475,7 +476,7 @@ export type CoreGradesGradeItem = { weightraw?: number; // Weight raw. weightformatted?: string; // Weight. status?: string; // Status. - graderaw?: number; // Grade raw. + graderaw?: SafeNumber; // Grade raw. gradedatesubmitted?: number; // Grade submit date. gradedategraded?: number; // Grade graded date. gradehiddenbydate?: boolean; // Grade hidden by date?. diff --git a/src/core/utils/types.d.ts b/src/core/utils/types.ts similarity index 63% rename from src/core/utils/types.d.ts rename to src/core/utils/types.ts index 0cfbe30e7..ff4aa5dba 100644 --- a/src/core/utils/types.d.ts +++ b/src/core/utils/types.ts @@ -51,3 +51,43 @@ export type Pretty = T extends infer U ? {[K in keyof U]: U[K]} : never; * @see https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types */ export type OmitUnion = T extends '' ? never : Omit; + +/** + * Helper to create branded types. + * + * A branded type can be used to mark other types as having passed some validations. + * + * @see https://twitter.com/mattpocockuk/status/1625173884885401600 + */ +export type Brand = T & { [brand]: TBrand }; + +declare const brand: unique symbol; + +/** + * Number type excluding NaN values. + */ +export type SafeNumber = Brand; + +/** + * Check whether a given number is safe to use (does not equal undefined nor NaN). + * + * @param value Number value. + * @returns Whether the number is safe. + */ +export function isSafeNumber(value?: unknown): value is SafeNumber { + return typeof value === 'number' && !isNaN(value); +} + +/** + * Make sure that a given number is safe to use, and convert it to undefined otherwise. + * + * @param value Number value. + * @returns Branded number value if safe, undefined otherwise. + */ +export function safeNumber(value?: unknown): SafeNumber | undefined { + if (!isSafeNumber(value)) { + return undefined; + } + + return value; +}