File

src/app/test-controller/services/test-loader.service.ts

Index

Properties
Methods

Constructor

constructor(appVersion: string, isProductionMode: boolean, tcs: TestControllerService, bs: BackendService, cts: CustomtextService)
Parameters :
Name Type Optional
appVersion string No
isProductionMode boolean No
tcs TestControllerService No
bs BackendService No
cts CustomtextService No

Methods

Private addTestletContentFromBookletXml
addTestletContentFromBookletXml(targetTestlet: Testlet, node: Element, navigationLeaveRestrictions: NavigationLeaveRestrictions)
Parameters :
Name Type Optional
targetTestlet Testlet No
node Element No
navigationLeaveRestrictions NavigationLeaveRestrictions No
Returns : void
Private getBookletFromXml
getBookletFromXml(xmlString: string)
Parameters :
Name Type Optional
xmlString string No
Returns : Testlet
Private Static getChildElements
getChildElements(element: Element)
Parameters :
Name Type Optional
element Element No
Returns : Element[]
Private incrementTotalProgress
incrementTotalProgress(progress: LoadingProgress, file: string)
Parameters :
Name Type Optional
progress LoadingProgress No
file string No
Returns : void
Async loadTest
loadTest()
Returns : Promise<void>
Private loadUnit
loadUnit(unitDef: UnitDef, sequenceId: number)
Parameters :
Name Type Optional
unitDef UnitDef No
sequenceId number No
Returns : Observable<number>
Private loadUnitContents
loadUnitContents()
Returns : Promise<void>
Private loadUnits
loadUnits()
Returns : Promise<number>
Private prepareUnitContentLoadingQueueOrder
prepareUnitContentLoadingQueueOrder(currentUnitId: string)
Parameters :
Name Type Optional Default value
currentUnitId string No '1'
Returns : void
reset
reset()
Returns : void
Private restoreRestrictions
restoreRestrictions(lastState)
Parameters :
Name Optional
lastState No
Returns : void
Private resumeTest
resumeTest(lastState)
Parameters :
Name Optional
lastState No
Returns : void
Private unsubscribeTestSubscriptions
unsubscribeTestSubscriptions()
Returns : void

Properties

Public appVersion
Type : string
Decorators :
@Inject('APP_VERSION')
Private environment
Type : EnvironmentData
Public isProductionMode
Type : boolean
Decorators :
@Inject('IS_PRODUCTION_MODE')
Private lastUnitSequenceId
Type : number
Default value : 0
Private loadStartTimeStamp
Type : number
Default value : 0
Public tcs
Type : TestControllerService
Private totalLoadingProgressParts
Type : literal type
Default value : {}
Private unitContentLoadingQueue
Type : TaggedString[]
Default value : []
Private unitContentLoadSubscription
Type : Subscription
Default value : null
import { Inject, Injectable } from '@angular/core';
import {
  BehaviorSubject, from, Observable, of, Subject, Subscription, throwError
} from 'rxjs';
import {
  concatMap, distinctUntilChanged, last, map, shareReplay, switchMap, tap
} from 'rxjs/operators';
import { CustomtextService } from 'iqb-components';
import {
  isLoadingFileLoaded, isNavigationLeaveRestrictionValue, LoadedFile, LoadingProgress, StateReportEntry, TaggedString,
  TestControllerState, TestData, TestLogEntryKey, TestStateKey, UnitNavigationTarget, UnitStateKey
} from '../interfaces/test-controller.interfaces';
import { TestMode } from '../../config/test-mode';
import {
  EnvironmentData, NavigationLeaveRestrictions, Testlet, UnitDef
} from '../classes/test-controller.classes';
import { MainDataService } from '../../maindata.service';
import { TestControllerService } from './test-controller.service';
import { BackendService } from './backend.service';
import { LocalStorage } from '../utils/local-storage.util';
// eslint-disable-next-line import/extensions
import { BookletConfig } from '../../config/booklet-config';

@Injectable({
  providedIn: 'root'
})
export class TestLoaderService {
  private loadStartTimeStamp = 0;
  private unitContentLoadSubscription: Subscription = null;
  private environment: EnvironmentData; // TODO (possible refactoring) outsource to a service or what
  private lastUnitSequenceId = 0;
  private unitContentLoadingQueue: TaggedString[] = [];
  private totalLoadingProgressParts: { [loadingId: string]: number } = {};

  constructor(
    @Inject('APP_VERSION') public appVersion: string,
    @Inject('IS_PRODUCTION_MODE') public isProductionMode: boolean,
    public tcs: TestControllerService,
    private bs: BackendService,
    private cts: CustomtextService
  ) {
  }

  async loadTest(): Promise<void> {
    let testData: TestData;
    try {
      this.reset();

      this.tcs.testStatus$.next(TestControllerState.LOADING);
      LocalStorage.setTestId(this.tcs.testId);

      testData = await this.bs.getTestData(this.tcs.testId).toPromise();
      this.tcs.testMode = new TestMode(testData.mode);
      this.restoreRestrictions(testData.laststate);
      this.tcs.rootTestlet = this.getBookletFromXml(testData.xml);

      await this.loadUnits();
      this.prepareUnitContentLoadingQueueOrder(testData.laststate.CURRENT_UNIT_ID || '1');
      this.tcs.rootTestlet.lockUnitsIfTimeLeftNull();
    } catch (e) {
      return Promise.reject(e);
    }
    return this.loadUnitContents()
      .then(() => {
        this.resumeTest(testData.laststate);
      });
    // when this promise resolves, it is allowed to start the test
  }

  reset(): void {
    this.unsubscribeTestSubscriptions();

    // Reset TestMode to be Demo, before the correct one comes with getTestData
    // TODO maybe it would be better to retrieve the testmode from the login
    this.tcs.testMode = new TestMode();
    this.tcs.resetDataStore();

    this.tcs.totalLoadingProgress = 0;
    this.totalLoadingProgressParts = {};

    this.environment = new EnvironmentData(this.appVersion);
    this.loadStartTimeStamp = Date.now();
    this.unitContentLoadingQueue = [];
  }

  private resumeTest(lastState: { [k in TestStateKey]?: string }): void {
    this.tcs.resumeTargetUnitSequenceId =
      this.tcs.rootTestlet.getSequenceIdByUnitAlias(lastState[TestStateKey.CURRENT_UNIT_ID]) || 1;
    if (lastState[TestStateKey.CONTROLLER] && (lastState[TestStateKey.CONTROLLER] === TestControllerState.PAUSED)) {
      this.tcs.testStatus$.next(TestControllerState.PAUSED);
      this.tcs.setUnitNavigationRequest(UnitNavigationTarget.PAUSE);
      return;
    }
    this.tcs.testStatus$.next(TestControllerState.RUNNING);
    this.tcs.setUnitNavigationRequest(this.tcs.resumeTargetUnitSequenceId.toString());
  }

  private restoreRestrictions(lastState: { [k in TestStateKey]?: string }): void {
    Object.keys(lastState).forEach(stateKey => {
      switch (stateKey) {
        case (TestStateKey.TESTLETS_TIMELEFT):
          this.tcs.maxTimeTimers = JSON.parse(lastState[stateKey]);
          break;
        case (TestStateKey.TESTLETS_CLEARED_CODE):
          this.tcs.clearCodeTestlets = JSON.parse(lastState[stateKey]);
          break;
        default:
      }
    });
  }

  private loadUnits(): Promise<number> {
    const sequence = [];
    for (let i = 1; i < this.lastUnitSequenceId; i++) {
      this.totalLoadingProgressParts[`unit-${i}`] = 0;
      this.totalLoadingProgressParts[`player-${i}`] = 0;
      this.totalLoadingProgressParts[`content-${i}`] = 0;
      sequence.push(i);
    }
    return from(sequence)
      .pipe(
        concatMap(nr => this.loadUnit(this.tcs.rootTestlet.getUnitAt(nr).unitDef, nr))
      )
      .toPromise();
  }

  private loadUnit(unitDef: UnitDef, sequenceId: number): Observable<number> {
    return this.bs.getUnitData(this.tcs.testId, unitDef.id, unitDef.alias)
      .pipe(
        switchMap(unit => {
          if (typeof unit === 'boolean') {
            return throwError(`error requesting unit ${this.tcs.testId}/${unitDef.id}`);
          }

          this.incrementTotalProgress({ progress: 100 }, `unit-${sequenceId}`);

          this.tcs.setOldUnitPresentationProgress(sequenceId, unit.state[UnitStateKey.PRESENTATION_PROGRESS]);
          this.tcs.setOldUnitResponseProgress(sequenceId, unit.state[UnitStateKey.RESPONSE_PROGRESS]);
          this.tcs.setOldUnitDataCurrentPage(sequenceId, unit.state[UnitStateKey.CURRENT_PAGE_ID]);

          try {
            const dataParts = unit.data ? JSON.parse(unit.data) : '';
            // TODO why has the above to be done. an issue in the simple-player?
            this.tcs.addUnitStateDataParts(sequenceId, dataParts);
          } catch (error) {
            console.warn(`error parsing unit state ${this.tcs.testId}/${unitDef.id} (${error.toString()})`, unit.data);
          }

          unitDef.playerId = unit.playerId;
          if (unit.definitionRef) {
            this.unitContentLoadingQueue.push(<TaggedString>{
              tag: sequenceId.toString(),
              value: unit.definitionRef
            });
          } else {
            this.tcs.addUnitDefinition(sequenceId, unit.definition);
            this.tcs.setUnitLoadProgress$(sequenceId, of({ progress: 100 }));
            this.incrementTotalProgress({ progress: 100 }, `content-${sequenceId}`);
          }

          if (this.tcs.hasPlayer(unit.playerId)) {
            this.incrementTotalProgress({ progress: 100 }, `player-${sequenceId}`);
            return of(sequenceId);
          }

          // this.tcs.addPlayer(unit.playerId, '');
          const playerFileId = TestControllerService.normaliseId(unit.playerId, 'html');
          return this.bs.getResource(this.tcs.testId, playerFileId, true)
            .pipe(
              tap((progress: LoadedFile | LoadingProgress) => {
                this.incrementTotalProgress(
                  isLoadingFileLoaded(progress) ? { progress: 100 } : progress,
                  `player-${sequenceId}`
                );
              }),
              last(),
              map((player: LoadedFile) => {
                this.tcs.addPlayer(unit.playerId, player.content);
                return sequenceId;
              })
            );
        })
      );
  }

  private prepareUnitContentLoadingQueueOrder(currentUnitId: string = '1'): void {
    const currentUnitSequenceId = this.tcs.rootTestlet.getSequenceIdByUnitAlias(currentUnitId);
    const queue = this.unitContentLoadingQueue;
    let firstToLoadQueuePosition;
    for (firstToLoadQueuePosition = 0; firstToLoadQueuePosition < queue.length; firstToLoadQueuePosition++) {
      if (Number(queue[firstToLoadQueuePosition % queue.length].tag) >= currentUnitSequenceId) {
        break;
      }
    }
    const offset = ((firstToLoadQueuePosition % queue.length) + queue.length) % queue.length;
    this.unitContentLoadingQueue = queue.slice(offset).concat(queue.slice(0, offset));
  }

  private loadUnitContents(): Promise<void> {
    // we don't load files in parallel since it made problems, when a whole class tried it at once
    const unitContentLoadingProgresses$: { [unitSequenceID: number] : Subject<LoadingProgress> } = {};
    this.unitContentLoadingQueue
      .forEach(unitToLoad => {
        unitContentLoadingProgresses$[Number(unitToLoad.tag)] =
          new BehaviorSubject<LoadingProgress>({ progress: 'PENDING' });
        this.tcs.setUnitLoadProgress$(
          Number(unitToLoad.tag),
          unitContentLoadingProgresses$[Number(unitToLoad.tag)].asObservable()
        );
      });

    return new Promise<void>((resolve, reject) => {
      if (this.tcs.bookletConfig.loading_mode === 'LAZY') {
        resolve();
      }

      this.unitContentLoadSubscription = from(this.unitContentLoadingQueue)
        .pipe(
          concatMap(queueEntry => {
            const unitSequenceID = Number(queueEntry.tag);

            const unitContentLoading$ = this.bs.getResource(this.tcs.testId, queueEntry.value)
              .pipe(shareReplay());

            unitContentLoading$
              .pipe(
                map(loadingFile => {
                  if (!isLoadingFileLoaded(loadingFile)) {
                    return loadingFile;
                  }
                  this.tcs.addUnitDefinition(unitSequenceID, loadingFile.content);
                  return { progress: 100 };
                }),
                distinctUntilChanged((v1, v2) => v1.progress === v2.progress),
                tap(progress => this.incrementTotalProgress(progress, `content-${unitSequenceID}`))
              )
              .subscribe(unitContentLoadingProgresses$[unitSequenceID]);

            return unitContentLoading$;
          })
        )
        .subscribe({
          error: reject,
          complete: () => {
            if (this.tcs.testMode.saveResponses) {
              this.environment.loadTime = Date.now() - this.loadStartTimeStamp;
              this.bs.addTestLog(this.tcs.testId, [<StateReportEntry>{
                key: TestLogEntryKey.LOADCOMPLETE, timeStamp: Date.now(), content: JSON.stringify(this.environment)
              }]);
            }
            this.tcs.totalLoadingProgress = 100;
            if (this.tcs.bookletConfig.loading_mode === 'EAGER') {
              resolve();
            }
          }
        });
    });
  }

  private unsubscribeTestSubscriptions(): void {
    if (this.unitContentLoadSubscription !== null) {
      this.unitContentLoadSubscription.unsubscribe();
      this.unitContentLoadSubscription = null;
    }
  }

  private static getChildElements(element: Element): Element[] {
    return Array.prototype.slice.call(element.childNodes)
      .filter(e => e.nodeType === 1);
  }

  private incrementTotalProgress(progress: LoadingProgress, file: string): void {
    if (typeof progress.progress !== 'number') {
      return;
    }
    this.totalLoadingProgressParts[file] = progress.progress;
    const sumOfProgresses = Object.values(this.totalLoadingProgressParts).reduce((i, a) => i + a, 0);
    const maxProgresses = Object.values(this.totalLoadingProgressParts).length * 100;
    this.tcs.totalLoadingProgress = (sumOfProgresses / maxProgresses) * 100;
  }

  private getBookletFromXml(xmlString: string): Testlet {
    let rootTestlet: Testlet = null;
    const oParser = new DOMParser();
    const oDOM = oParser.parseFromString(xmlString, 'text/xml');

    if (oDOM.documentElement.nodeName !== 'Booklet') {
      throw Error('Root element fo Booklet should be <Booklet>');
    }
    const metadataElements = oDOM.documentElement.getElementsByTagName('Metadata');
    if (metadataElements.length === 0) {
      throw Error('<Metadata>-Element missing');
    }
    const metadataElement = metadataElements[0];
    const IdElement = metadataElement.getElementsByTagName('Id')[0];
    const LabelElement = metadataElement.getElementsByTagName('Label')[0];
    rootTestlet = new Testlet(0, IdElement.textContent, LabelElement.textContent);
    const unitsElements = oDOM.documentElement.getElementsByTagName('Units');
    if (unitsElements.length > 0) {
      const customTextsElements = oDOM.documentElement.getElementsByTagName('CustomTexts');
      if (customTextsElements.length > 0) {
        const customTexts = TestLoaderService.getChildElements(customTextsElements[0]);
        const customTextsForBooklet = {};
        for (let childIndex = 0; childIndex < customTexts.length; childIndex++) {
          if (customTexts[childIndex].nodeName === 'Text') {
            const customTextKey = customTexts[childIndex].getAttribute('key');
            if ((typeof customTextKey !== 'undefined') && (customTextKey !== null)) {
              customTextsForBooklet[customTextKey] = customTexts[childIndex].textContent;
            }
          }
        }
        this.cts.addCustomTexts(customTextsForBooklet);
      }

      const bookletConfigElements = oDOM.documentElement.getElementsByTagName('BookletConfig');

      this.tcs.bookletConfig = new BookletConfig();
      this.tcs.bookletConfig.setFromKeyValuePairs(MainDataService.getTestConfig());
      if (bookletConfigElements.length > 0) {
        this.tcs.bookletConfig.setFromXml(bookletConfigElements[0]);
      }

      // recursive call through all testlets
      this.lastUnitSequenceId = 1;
      this.tcs.allUnitIds = [];
      this.addTestletContentFromBookletXml(
        rootTestlet,
        unitsElements[0],
        new NavigationLeaveRestrictions(
          this.tcs.bookletConfig.force_presentation_complete,
          this.tcs.bookletConfig.force_response_complete
        )
      );
    }
    return rootTestlet;
  }

  private addTestletContentFromBookletXml(targetTestlet: Testlet, node: Element,
                                          navigationLeaveRestrictions: NavigationLeaveRestrictions) {
    const childElements = TestLoaderService.getChildElements(node);
    if (childElements.length > 0) {
      let codeToEnter = '';
      let codePrompt = '';
      let maxTime = -1;
      let forcePresentationComplete = navigationLeaveRestrictions.presentationComplete;
      let forceResponseComplete = navigationLeaveRestrictions.responseComplete;

      let restrictionElement: Element = null;
      for (let childIndex = 0; childIndex < childElements.length; childIndex++) {
        if (childElements[childIndex].nodeName === 'Restrictions') {
          restrictionElement = childElements[childIndex];
          break;
        }
      }
      if (restrictionElement !== null) {
        const restrictionElements = TestLoaderService.getChildElements(restrictionElement);
        for (let childIndex = 0; childIndex < restrictionElements.length; childIndex++) {
          if (restrictionElements[childIndex].nodeName === 'CodeToEnter') {
            const restrictionParameter = restrictionElements[childIndex].getAttribute('code');
            if ((typeof restrictionParameter !== 'undefined') && (restrictionParameter !== null)) {
              codeToEnter = restrictionParameter.toUpperCase();
              codePrompt = restrictionElements[childIndex].textContent;
            }
          }
          if (restrictionElements[childIndex].nodeName === 'TimeMax') {
            const restrictionParameter = restrictionElements[childIndex].getAttribute('minutes');
            if ((typeof restrictionParameter !== 'undefined') && (restrictionParameter !== null)) {
              maxTime = Number(restrictionParameter);
              if (Number.isNaN(maxTime)) {
                maxTime = -1;
              }
            }
          }
          if (restrictionElements[childIndex].nodeName === 'DenyNavigationOnIncomplete') {
            const presentationComplete = restrictionElements[childIndex].getAttribute('presentation');
            if (isNavigationLeaveRestrictionValue(presentationComplete)) {
              forcePresentationComplete = presentationComplete;
            }
            const responseComplete = restrictionElements[childIndex].getAttribute('response');
            if (isNavigationLeaveRestrictionValue(responseComplete)) {
              forceResponseComplete = responseComplete;
            }
          }
        }
      }

      if (codeToEnter.length > 0) {
        targetTestlet.codeToEnter = codeToEnter;
        targetTestlet.codePrompt = codePrompt;
      }
      targetTestlet.maxTimeLeft = maxTime;
      if (this.tcs.maxTimeTimers) {
        if (targetTestlet.id in this.tcs.maxTimeTimers) {
          targetTestlet.maxTimeLeft = this.tcs.maxTimeTimers[targetTestlet.id];
        }
      }
      const newNavigationLeaveRestrictions =
        new NavigationLeaveRestrictions(forcePresentationComplete, forceResponseComplete);

      for (let childIndex = 0; childIndex < childElements.length; childIndex++) {
        if (childElements[childIndex].nodeName === 'Unit') {
          const unitId = childElements[childIndex].getAttribute('id');
          let unitAlias = childElements[childIndex].getAttribute('alias');
          if (!unitAlias) {
            unitAlias = unitId;
          }
          let unitAliasClear = unitAlias;
          let unitIdSuffix = 1;
          while (this.tcs.allUnitIds.indexOf(unitAliasClear) > -1) {
            unitAliasClear = `${unitAlias}-${unitIdSuffix.toString()}`;
            unitIdSuffix += 1;
          }
          this.tcs.allUnitIds.push(unitAliasClear);

          targetTestlet.addUnit(
            this.lastUnitSequenceId,
            unitId,
            childElements[childIndex].getAttribute('label'),
            unitAliasClear,
            childElements[childIndex].getAttribute('labelshort'),
            newNavigationLeaveRestrictions
          );
          this.lastUnitSequenceId += 1;
        } else if (childElements[childIndex].nodeName === 'Testlet') {
          const testletId: string = childElements[childIndex].getAttribute('id');
          let testletLabel: string = childElements[childIndex].getAttribute('label');
          testletLabel = testletLabel ? testletLabel.trim() : '';

          this.addTestletContentFromBookletXml(
            targetTestlet.addTestlet(testletId, testletLabel),
            childElements[childIndex],
            newNavigationLeaveRestrictions
          );
        }
      }
    }
  }
}

results matching ""

    No results matching ""