Skip to content

Commit

Permalink
MOBILE-4690 quiz: Calculated depends on numerical like in LMS
Browse files Browse the repository at this point in the history
  • Loading branch information
crazyserver committed Jan 16, 2025
1 parent b6b6aa2 commit 3ac0f96
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 312 deletions.
11 changes: 0 additions & 11 deletions src/addons/qtype/calculated/calculated.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,10 @@

import { APP_INITIALIZER, NgModule } from '@angular/core';

import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeCalculatedComponent } from './component/calculated';
import { AddonQtypeCalculatedHandler } from './services/handlers/calculated';

@NgModule({
declarations: [
AddonQtypeCalculatedComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
Expand All @@ -35,8 +27,5 @@ import { AddonQtypeCalculatedHandler } from './services/handlers/calculated';
},
},
],
exports: [
AddonQtypeCalculatedComponent,
],
})
export class AddonQtypeCalculatedModule {}
222 changes: 5 additions & 217 deletions src/addons/qtype/calculated/services/handlers/calculated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,233 +12,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Injectable, Type } from '@angular/core';
import { Injectable } from '@angular/core';
import { AddonQtypeNumericalHandlerService } from '@addons/qtype/numerical/services/handlers/numerical';

import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { convertTextToHTMLElement } from '@/core/utils/create-html-element';
import { CoreObject } from '@singletons/object';
import { makeSingleton, Translate } from '@singletons';
import { makeSingleton } from '@singletons';

/**
* Handler to support calculated question type.
* This question type depends on numeric question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeCalculatedHandlerService implements CoreQuestionHandler {

static readonly UNITINPUT = '0';
static readonly UNITRADIO = '1';
static readonly UNITSELECT = '2';
static readonly UNITNONE = '3';

static readonly UNITGRADED = '1';
static readonly UNITOPTIONAL = '0';
export class AddonQtypeCalculatedHandlerService extends AddonQtypeNumericalHandlerService {

name = 'AddonQtypeCalculated';
type = 'qtype_calculated';

/**
* @inheritdoc
*/
async getComponent(): Promise<Type<unknown>> {
const { AddonQtypeCalculatedComponent } = await import('../../component/calculated');

return AddonQtypeCalculatedComponent;
}

/**
* Check if the units are in a separate field for the question.
*
* @param question Question.
* @returns Whether units are in a separate field.
*/
hasSeparateUnitField(question: CoreQuestionQuestionParsed): boolean {
if (!question.parsedSettings) {
const element = convertTextToHTMLElement(question.html);

return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
}

return question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITRADIO ||
question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITSELECT;
}

/**
* @inheritdoc
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
if (!this.isGradableResponse(question, answers)) {
return 0;
}

const { answer, unit } = this.parseAnswer(question, <string> answers.answer);
if (answer === null) {
return 0;
}

if (!question.parsedSettings) {
if (this.hasSeparateUnitField(question)) {
return this.isValidValue(<string> answers.unit) ? 1 : 0;
}

// We cannot know if the answer should contain units or not.
return -1;
}

if (question.parsedSettings.unitdisplay !== AddonQtypeCalculatedHandlerService.UNITINPUT && unit) {
// There should be no units or be outside of the input, not valid.
return 0;
}

if (this.hasSeparateUnitField(question) && !this.isValidValue(<string> answers.unit)) {
// Unit not supplied as a separate field and it's required.
return 0;
}

if (question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITINPUT &&
question.parsedSettings.unitgradingtype === AddonQtypeCalculatedHandlerService.UNITGRADED &&
!this.isValidValue(unit)) {
// Unit not supplied inside the input and it's required.
return 0;
}

return 1;
}

/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return true;
}

/**
* @inheritdoc
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
return this.isValidValue(<string> answers.answer) ? 1 : 0;
}

/**
* @inheritdoc
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreObject.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') &&
CoreObject.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit');
}

/**
* Check if a value is valid (not empty).
*
* @param value Value to check.
* @returns Whether the value is valid.
*/
isValidValue(value: string | number | null): boolean {
return !!value || value === '0' || value === 0;
}

/**
* Parse an answer string.
*
* @param question Question.
* @param answer Answer.
* @returns Answer and unit.
*/
parseAnswer(question: CoreQuestionQuestionParsed, answer: string): { answer: number | null; unit: string | null } {
if (!answer) {
return { answer: null, unit: null };
}

let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';

// Strip spaces (which may be thousands separators) and change other forms of writing e to e.
answer = answer.replace(/ /g, '');
answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1');

// If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
// Else assume it is a decimal separator, and change it to '.'.
if (answer.indexOf('.') !== -1 || answer.split(',').length - 1 > 1) {
answer = answer.replace(',', '');
} else {
answer = answer.replace(',', '.');
}

let unitsLeft = false;
let match: RegExpMatchArray | null = null;

if (!question.parsedSettings || question.parsedSettings.unitsleft === null) {
// We don't know if units should be before or after so we check both.
match = answer.match(new RegExp('^' + regexString));
if (!match) {
unitsLeft = true;
match = answer.match(new RegExp(regexString + '$'));
}
} else {
unitsLeft = question.parsedSettings.unitsleft === '1';
regexString = unitsLeft ? regexString + '$' : '^' + regexString;

match = answer.match(new RegExp(regexString));
}

if (!match) {
return { answer: null, unit: null };
}

const numberString = match[0];
const unit = unitsLeft ? answer.substring(0, answer.length - match[0].length) : answer.substring(match[0].length);

// No need to calculate the multiplier.
return { answer: Number(numberString), unit };
}

/**
* @inheritdoc
*/
getValidationError(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): string | undefined {
if (!this.isGradableResponse(question, answers)) {
return Translate.instant('addon.qtype_numerical.pleaseenterananswer');
}

const { answer, unit } = this.parseAnswer(question, <string> answers.answer);
if (answer === null) {
return Translate.instant('addon.qtype_numerica.invalidnumber');
}

if (!question.parsedSettings) {
if (this.hasSeparateUnitField(question)) {
return Translate.instant('addon.qtype_numerica.unitnotselected');
}

// We cannot know if the answer should contain units or not.
return;
}

if (question.parsedSettings.unitdisplay !== AddonQtypeCalculatedHandlerService.UNITINPUT && unit) {
return Translate.instant('addon.qtype_numerica.invalidnumbernounit');
}

if (question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITINPUT &&
question.parsedSettings.unitgradingtype === AddonQtypeCalculatedHandlerService.UNITGRADED &&
!this.isValidValue(unit)) {
return Translate.instant('addon.qtype_numerica.invalidnumber');
}

return;
}

}

export const AddonQtypeCalculatedHandler = makeSingleton(AddonQtypeCalculatedHandlerService);
Original file line number Diff line number Diff line change
Expand Up @@ -12,83 +12,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Injectable, Type } from '@angular/core';
import { Injectable } from '@angular/core';

import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { AddonQtypeCalculatedHandler } from '@addons/qtype/calculated/services/handlers/calculated';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { makeSingleton } from '@singletons';
import { AddonQtypeNumericalHandlerService } from '@addons/qtype/numerical/services/handlers/numerical';

/**
* Handler to support calculated simple question type.
* This question type depends on numeric question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeCalculatedSimpleHandlerService implements CoreQuestionHandler {
export class AddonQtypeCalculatedSimpleHandlerService extends AddonQtypeNumericalHandlerService {

name = 'AddonQtypeCalculatedSimple';
type = 'qtype_calculatedsimple';

/**
* @inheritdoc
*/
async getComponent(): Promise<Type<unknown>> {
// Calculated simple behaves like a calculated, use the same component.
const { AddonQtypeCalculatedComponent } = await import('@addons/qtype/calculated/component/calculated');

return AddonQtypeCalculatedComponent;
}

/**
* @inheritdoc
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// This question type depends on calculated.
return AddonQtypeCalculatedHandler.isCompleteResponse(question, answers);
}

/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return true;
}

/**
* @inheritdoc
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// This question type depends on calculated.
return AddonQtypeCalculatedHandler.isGradableResponse(question, answers);
}

/**
* @inheritdoc
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
// This question type depends on calculated.
return AddonQtypeCalculatedHandler.isSameResponse(question, prevAnswers, newAnswers);
}

/**
* @inheritdoc
*/
getValidationError(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): string | undefined {
return AddonQtypeCalculatedHandler.getValidationError(question, answers);
}

}

export const AddonQtypeCalculatedSimpleHandler = makeSingleton(AddonQtypeCalculatedSimpleHandlerService);
1 change: 1 addition & 0 deletions src/addons/qtype/ddwtos/services/handlers/ddwtos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { makeSingleton } from '@singletons';

/**
* Handler to support drag-and-drop words into sentences question type.
* This question type is a variation of gapselect.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeDdwtosHandlerService extends AddonQtypeGapSelectHandlerService {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ion-list class="addon-qtype-calculated-container" *ngIf="question && (question.text || question.text === '')">
<ion-list class="addon-qtype-numerical-container" *ngIf="question && (question.text || question.text === '')">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.text" [contextLevel]="contextLevel"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@

import { Component, ElementRef } from '@angular/core';

import { AddonModQuizCalculatedQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { AddonModQuizNumericalQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';

/**
* Component to render a calculated question.
* Component to render a numerical question.
*/
@Component({
selector: 'addon-qtype-calculated',
templateUrl: 'addon-qtype-calculated.html',
styleUrl: 'calculated.scss',
selector: 'numerical',
templateUrl: 'numerical.html',
styleUrl: 'numerical.scss',
})
export class AddonQtypeCalculatedComponent extends CoreQuestionBaseComponent<AddonModQuizCalculatedQuestion> {
export class AddonQtypeNumericalComponent extends CoreQuestionBaseComponent<AddonModQuizNumericalQuestion> {

constructor(elementRef: ElementRef) {
super('AddonQtypeCalculatedComponent', elementRef);
super('AddonQtypeNumericalComponent', elementRef);
}

/**
Expand Down
File renamed without changes.
Loading

0 comments on commit 3ac0f96

Please sign in to comment.