From 22d94c7d3b31eab37cb391533ea6f055ff890d6d Mon Sep 17 00:00:00 2001 From: paflov Date: Tue, 23 Jan 2024 15:01:08 +0100 Subject: [PATCH] [Frontend] Major refactoring of Test-Controller to avoid duplicate structures I --- docker/docker-compose.dev.yml | 4 +- .../group-monitor/booklet/booklet.service.ts | 158 ++----- .../app/group-monitor/booklet/booklet.util.ts | 9 +- .../group-monitor/group-monitor.interfaces.ts | 95 ++-- .../test-session-manager.service.spec.ts | 4 +- .../test-session/test-session.component.ts | 7 +- .../test-session/test-session.util.spec.ts | 2 +- .../unit-test-example-data.spec.ts | 2 +- .../shared/interfaces/booklet.interfaces.ts | 80 ++++ .../shared/services/bookletParser.service.ts | 177 +++++++ frontend/src/app/shared/shared.module.ts | 1 + .../classes/test-controller.classes.ts | 446 +++++++++--------- .../test-controller.component.ts | 38 +- .../unithost/unithost.component.html | 10 +- .../components/unithost/unithost.component.ts | 47 +- .../interfaces/test-controller.interfaces.ts | 25 + .../routing/unit-activate.guard.ts | 7 +- .../routing/unit-deactivate.guard.ts | 33 +- .../services/test-controller.service.ts | 70 ++- .../services/test-loader.service.ts | 271 ++++------- 20 files changed, 786 insertions(+), 700 deletions(-) create mode 100644 frontend/src/app/shared/interfaces/booklet.interfaces.ts create mode 100644 frontend/src/app/shared/services/bookletParser.service.ts diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 926b194b5..dd5021f67 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -50,8 +50,8 @@ services: - ../backend/config/no-cors.htaccess/:/var/www/.htaccess - ../backend/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - ../backend/config/local.php.ini:/usr/local/etc/php/conf.d/local.ini - environment: - TLS_ENABLED: ${TLS_ENABLED:-off} +# environment: +# TLS_ENABLED: ${TLS_ENABLED:-off} ports: - 9005:9003 depends_on: diff --git a/frontend/src/app/group-monitor/booklet/booklet.service.ts b/frontend/src/app/group-monitor/booklet/booklet.service.ts index 342f21c35..3d87e0f60 100644 --- a/frontend/src/app/group-monitor/booklet/booklet.service.ts +++ b/frontend/src/app/group-monitor/booklet/booklet.service.ts @@ -3,17 +3,22 @@ import { Observable, of } from 'rxjs'; import { map, shareReplay } from 'rxjs/operators'; import { BackendService } from '../backend.service'; import { - Booklet, BookletError, BookletMetadata, isTestlet, isUnit, Restrictions, Testlet, Unit + isUnit, Booklet, Testlet, BookletError, Unit } from '../group-monitor.interfaces'; -import { BookletConfig } from '../../shared/classes/booklet-config.class'; +import { BookletParserService } from '../../shared/services/bookletParser.service'; +import { + BookletDef, ContextInBooklet, TestletDef, UnitDef +} from '../../shared/shared.module'; @Injectable() -export class BookletService { +export class BookletService extends BookletParserService { booklets: { [k: string]: Observable } = {}; constructor( private bs: BackendService - ) { } + ) { + super(); + } getBooklet(bookletName = ''): Observable { if (typeof this.booklets[bookletName] !== 'undefined') { @@ -25,149 +30,42 @@ export class BookletService { this.booklets[bookletName] = this.bs.getBooklet(bookletName) .pipe( // eslint-disable-next-line max-len - map((response: string | BookletError) => (typeof response === 'string' ? BookletService.parseBookletXml(response) : response)), + map((response: string | BookletError) => (typeof response === 'string' ? this.parseXml(response) : response)), shareReplay(1) ); } return this.booklets[bookletName]; } - private static parseBookletXml(xmlString: string): Booklet | BookletError { + parseXml(xmlString: string): Booklet | BookletError { try { - const domParser = new DOMParser(); - const xmlStringWithOutBom = xmlString.replace(/^\uFEFF/gm, ''); - const bookletElement = domParser.parseFromString(xmlStringWithOutBom, 'text/xml').documentElement; - - if (bookletElement.nodeName !== 'Booklet') { - // console.warn('XML-root is not `Booklet`'); - return { error: 'xml', species: null }; - } - - const units = BookletService.xmlGetChildIfExists(bookletElement, 'Units'); - if (units == null) { - return { error: 'xml', species: null }; - } - - const metadata = BookletService.parseMetadata(bookletElement); - if (metadata == null) { - return { error: 'xml', species: null }; - } - - const parsedBooklet: Booklet = { - units: BookletService.parseTestlet(units), - metadata: metadata, - config: BookletService.parseBookletConfig(bookletElement), - species: '' - }; - BookletService.addBookletStructureInformation(parsedBooklet); - return parsedBooklet; + return this.parseBookletXml(xmlString); } catch (error) { - // console.warn('Error reading booklet XML:', error); return { error: 'xml', species: null }; } } - private static addBookletStructureInformation(booklet: Booklet): void { - booklet.species = BookletService.getBookletSpecies(booklet); - booklet.units.children - .filter(isTestlet) - .forEach((block: Testlet, index, blocks) => { - block.blockId = `block ${index + 1}`; - if (index < blocks.length - 1) { - block.nextBlockId = `block ${index + 2}`; - } - }); + toBooklet(bookletDef: BookletDef): Booklet { + return Object.assign(bookletDef, { + species: this.getBookletSpecies(bookletDef) + }); } - private static getBookletSpecies(booklet: Booklet): string { - return `species: ${booklet.units.children.filter(testletOrUnit => !isUnit(testletOrUnit)).length}`; + toTestlet(testletDef: TestletDef, elem: Element, context: ContextInBooklet): Testlet { + return Object.assign(testletDef, { + descendantCount: this.xmlCountChildrenOfTagNames(elem, ['Unit']), + blockId: `block ${context.localIndexOfTestlets + 1}`, + nextBlockId: `block ${context.localIndexOfTestlets + 2}` + }); } - private static parseBookletConfig(bookletElement: Element): BookletConfig { - const bookletConfigElements = BookletService.xmlGetChildIfExists(bookletElement, 'BookletConfig', true); - const bookletConfig = new BookletConfig(); - if (bookletConfigElements) { - bookletConfig.setFromXml(bookletConfigElements); - } - return bookletConfig; + // eslint-disable-next-line class-methods-use-this + toUnit(unitDef: UnitDef): Unit { + return unitDef; } - private static parseMetadata(bookletElement: Element): BookletMetadata | null { - const metadataElement = BookletService.xmlGetChildIfExists(bookletElement, 'Metadata'); - if (!metadataElement) { - return null; - } - return { - id: BookletService.xmlGetChildTextIfExists(metadataElement, 'Id'), - label: BookletService.xmlGetChildTextIfExists(metadataElement, 'Label'), - description: BookletService.xmlGetChildTextIfExists(metadataElement, 'Description', true) - }; - } - - private static parseTestlet(testletElement: Element): Testlet { - return { - id: testletElement.getAttribute('id') || '', - label: testletElement.getAttribute('label') || '', - restrictions: BookletService.parseRestrictions(testletElement), - children: BookletService.xmlGetDirectChildrenByTagName(testletElement, ['Unit', 'Testlet']) - .map(BookletService.parseUnitOrTestlet), - descendantCount: BookletService.xmlCountChildrenOfTagNames(testletElement, ['Unit']) - }; - } - - private static parseUnitOrTestlet(unitOrTestletElement: Element): (Unit | Testlet) { - if (unitOrTestletElement.tagName === 'Unit') { - return { - id: unitOrTestletElement.getAttribute('alias') || unitOrTestletElement.getAttribute('id') || '', - label: unitOrTestletElement.getAttribute('label') || '', - labelShort: unitOrTestletElement.getAttribute('labelshort') || '' - }; - } - return BookletService.parseTestlet(unitOrTestletElement); - } - - private static parseRestrictions(testletElement: Element): Restrictions { - const restrictions: Restrictions = {}; - const restrictionsElement = BookletService.xmlGetChildIfExists(testletElement, 'Restrictions', true); - if (!restrictionsElement) { - return restrictions; - } - const codeToEnterElement = restrictionsElement.querySelector('CodeToEnter'); - if (codeToEnterElement) { - restrictions.codeToEnter = { - code: codeToEnterElement.getAttribute('code') || '', - message: codeToEnterElement.textContent || '' - }; - } - const timeMaxElement = restrictionsElement.querySelector('TimeMax'); - if (timeMaxElement) { - restrictions.timeMax = { - minutes: parseFloat(timeMaxElement.getAttribute('minutes') || '') - }; - } - return restrictions; - } - - private static xmlGetChildIfExists(element: Element, childName: string, isOptional = false): Element | null { - const elements = BookletService.xmlGetDirectChildrenByTagName(element, [childName]); - if (!elements.length && !isOptional) { - throw new Error(`Missing field: '${childName}'`); - } - return elements.length ? elements[0] : null; - } - - private static xmlGetChildTextIfExists(element: Element, childName: string, isOptional = false): string { - const childElement = BookletService.xmlGetChildIfExists(element, childName, isOptional); - return (childElement && childElement.textContent) ? childElement.textContent : ''; - } - - private static xmlGetDirectChildrenByTagName(element: Element, tagNames: string[]): Element[] { - return [].slice.call(element.childNodes) - .filter((elem: Element) => (elem.nodeType === 1)) - .filter((elem: Element) => (tagNames.indexOf(elem.tagName) > -1)); - } - - private static xmlCountChildrenOfTagNames(element: Element, tagNames: string[]): number { - return element.querySelectorAll(tagNames.join(', ')).length; + // eslint-disable-next-line class-methods-use-this + getBookletSpecies(booklet: BookletDef): string { + return `species: ${booklet.units.children.filter(testletOrUnit => !isUnit(testletOrUnit)).length}`; } } diff --git a/frontend/src/app/group-monitor/booklet/booklet.util.ts b/frontend/src/app/group-monitor/booklet/booklet.util.ts index d6dd9ba5c..460d2841a 100644 --- a/frontend/src/app/group-monitor/booklet/booklet.util.ts +++ b/frontend/src/app/group-monitor/booklet/booklet.util.ts @@ -1,9 +1,10 @@ import { - Booklet, isTestlet, isUnit, Testlet, Unit + Booklet, isTestlet, isUnit, Testlet } from '../group-monitor.interfaces'; +import { UnitDef } from '../../shared/interfaces/booklet.interfaces'; export class BookletUtil { - static getFirstUnit(testletOrUnit: Testlet | Unit): Unit | null { + static getFirstUnit(testletOrUnit: Testlet | UnitDef): UnitDef | null { while (!isUnit(testletOrUnit)) { if (!testletOrUnit.children.length) { return null; @@ -14,9 +15,9 @@ export class BookletUtil { return testletOrUnit; } - static getFirstUnitOfBlock(blockId: string, booklet: Booklet): Unit | null { + static getFirstUnitOfBlock(blockId: string, booklet: Booklet): UnitDef | null { for (let i = 0; i < booklet.units.children.length; i++) { - const child = booklet.units.children[i]; + const child = booklet.units.children[i] as Testlet; if (!isUnit(child) && (child.blockId === blockId)) { return BookletUtil.getFirstUnit(child); } diff --git a/frontend/src/app/group-monitor/group-monitor.interfaces.ts b/frontend/src/app/group-monitor/group-monitor.interfaces.ts index bef8bb47b..9ee4f6b15 100644 --- a/frontend/src/app/group-monitor/group-monitor.interfaces.ts +++ b/frontend/src/app/group-monitor/group-monitor.interfaces.ts @@ -1,6 +1,5 @@ import { TestSessionChange } from 'testcenter-common/interfaces/test-session-change.interface'; -// eslint-disable-next-line import/extensions -import { BookletConfig } from '../shared/shared.module'; +import { BookletDef, TestletDef, UnitDef } from '../shared/shared.module'; export interface GroupMonitorConfig { checkForIdleInterval: number; @@ -22,59 +21,6 @@ export const TestSessionsSuperStates = ['monitor_group', 'demo', 'pending', 'loc 'connection_websocket', 'connection_polling', 'ok'] as const; export type TestSessionSuperState = typeof TestSessionsSuperStates[number]; -export interface Booklet { - metadata: BookletMetadata; - config: BookletConfig; - restrictions?: Restrictions; - units: Testlet; - species: string; -} - -export interface BookletError { - error: 'xml' | 'missing-id' | 'missing-file' | 'general'; - species: null; -} - -export function isBooklet(bookletOrError: Booklet | BookletError): bookletOrError is Booklet { - return bookletOrError && !('error' in bookletOrError); -} - -export interface BookletMetadata { - id: string; - label: string; - description: string; - owner?: string; - lastchange?: string; - status?: string; - project?: string; -} - -export interface Testlet { - id: string; - label: string; - restrictions?: Restrictions; - children: (Unit | Testlet)[]; - descendantCount: number; - blockId?: string; - nextBlockId?: string; -} - -export interface Unit { - id: string; - label: string; - labelShort: string; -} - -export interface Restrictions { - codeToEnter?: { - code: string; - message: string; - }; - timeMax?: { - minutes: number - }; -} - export type TestViewDisplayOptionKey = keyof TestViewDisplayOptions; export interface TestSessionFilter { @@ -99,16 +45,8 @@ export interface CheckingOptions { autoCheckAll: boolean; } -export function isUnit(testletOrUnit: Testlet | Unit): testletOrUnit is Unit { - return !('children' in testletOrUnit); -} - -export function isTestlet(testletOrUnit: Testlet | Unit): testletOrUnit is Testlet { - return !isUnit(testletOrUnit); -} - export interface UnitContext { - unit?: Unit; + unit?: UnitDef; parent?: Testlet; ancestor?: Testlet; indexGlobal: number; @@ -150,3 +88,32 @@ export interface GotoCommandData { firstUnitId: string } } + +export type Unit = UnitDef; + +export interface Booklet extends BookletDef { + species: string; +} + +export interface Testlet extends TestletDef { + descendantCount: number; + blockId?: string; + nextBlockId?: string; +} + +export function isUnit(testletOrUnit: Testlet | UnitDef): testletOrUnit is UnitDef { + return !('children' in testletOrUnit); +} + +export function isTestlet(testletOrUnit: Testlet | UnitDef): testletOrUnit is Testlet { + return !isUnit(testletOrUnit); +} + +export function isBooklet(bookletOrError: Booklet | BookletError): bookletOrError is Booklet { + return bookletOrError && !('error' in bookletOrError); +} + +export interface BookletError { + error: 'xml' | 'missing-id' | 'missing-file' | 'general'; + species: null; +} diff --git a/frontend/src/app/group-monitor/test-session-manager/test-session-manager.service.spec.ts b/frontend/src/app/group-monitor/test-session-manager/test-session-manager.service.spec.ts index 36c13b314..c8b36abee 100644 --- a/frontend/src/app/group-monitor/test-session-manager/test-session-manager.service.spec.ts +++ b/frontend/src/app/group-monitor/test-session-manager/test-session-manager.service.spec.ts @@ -4,8 +4,8 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { Observable, of } from 'rxjs'; import { Pipe } from '@angular/core'; import { - Booklet, BookletError, CommandResponse, GroupMonitorConfig, - Selected, Testlet, TestSessionData, TestSessionFilter, TestSessionSetStats + BookletDef, BookletError, CommandResponse, GroupMonitorConfig, + Selected, TestletDef, TestSessionData, TestSessionFilter, TestSessionSetStats } from '../group-monitor.interfaces'; import { BookletService } from '../booklet/booklet.service'; import { BackendService } from '../backend.service'; diff --git a/frontend/src/app/group-monitor/test-session/test-session.component.ts b/frontend/src/app/group-monitor/test-session/test-session.component.ts index 797c0afb3..1123b5136 100644 --- a/frontend/src/app/group-monitor/test-session/test-session.component.ts +++ b/frontend/src/app/group-monitor/test-session/test-session.component.ts @@ -3,13 +3,14 @@ import { } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { - Testlet, Unit, TestViewDisplayOptions, + Testlet as Testlet, TestViewDisplayOptions, isUnit, Selected, TestSession, TestSessionSuperState } from '../group-monitor.interfaces'; import { TestSessionUtil } from './test-session.util'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { superStates } from './super-states'; +import { UnitDef } from '../../shared/interfaces/booklet.interfaces'; interface IconData { icon: string, @@ -41,10 +42,10 @@ export class TestSessionComponent { hasState = TestSessionUtil.hasState; // eslint-disable-next-line class-methods-use-this - getTestletType = (testletOrUnit: Unit | Testlet): 'testlet' | 'unit' => (isUnit(testletOrUnit) ? 'unit' : 'testlet'); + getTestletType = (testletOrUnit: UnitDef | Testlet): 'testlet' | 'unit' => (isUnit(testletOrUnit) ? 'unit' : 'testlet'); // eslint-disable-next-line class-methods-use-this - trackUnits = (index: number, testlet: Testlet | Unit): string => testlet.id || index.toString(); + trackUnits = (index: number, testlet: Testlet | UnitDef): string => testlet.id || index.toString(); mark(testletOrNull: Testlet | null = null): void { if ((testletOrNull != null) && !testletOrNull.blockId) { diff --git a/frontend/src/app/group-monitor/test-session/test-session.util.spec.ts b/frontend/src/app/group-monitor/test-session/test-session.util.spec.ts index 417e4a8f7..24859b740 100644 --- a/frontend/src/app/group-monitor/test-session/test-session.util.spec.ts +++ b/frontend/src/app/group-monitor/test-session/test-session.util.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable object-curly-newline */ import { TestSessionUtil } from './test-session.util'; import { unitTestExampleBooklets } from '../unit-test-example-data.spec'; -import { Testlet, UnitContext } from '../group-monitor.interfaces'; +import { TestletDef, UnitContext } from '../group-monitor.interfaces'; describe('TestSessionUtil', () => { describe('getCurrent()', () => { diff --git a/frontend/src/app/group-monitor/unit-test-example-data.spec.ts b/frontend/src/app/group-monitor/unit-test-example-data.spec.ts index 80c0100e9..c12b72960 100644 --- a/frontend/src/app/group-monitor/unit-test-example-data.spec.ts +++ b/frontend/src/app/group-monitor/unit-test-example-data.spec.ts @@ -1,6 +1,6 @@ import { TestSessionChange } from 'testcenter-common/interfaces/test-session-change.interface'; import { - Booklet, CommandResponse, TestSession, TestSessionData, TestSessionSetStats + BookletDef, CommandResponse, TestSession, TestSessionData, TestSessionSetStats } from './group-monitor.interfaces'; import { TestSessionUtil } from './test-session/test-session.util'; import { BookletConfig } from '../shared/classes/booklet-config.class'; diff --git a/frontend/src/app/shared/interfaces/booklet.interfaces.ts b/frontend/src/app/shared/interfaces/booklet.interfaces.ts new file mode 100644 index 000000000..b3c374fd7 --- /dev/null +++ b/frontend/src/app/shared/interfaces/booklet.interfaces.ts @@ -0,0 +1,80 @@ +// currently group-monitor and test-controller have parallel structures to represent booklets +// goal is to use the same interfaces in both areas: those in this file. + +import { BookletConfig } from '../classes/booklet-config.class'; + +export interface BookletDef { + readonly metadata: BookletMetadata; + readonly config: BookletConfig; + readonly restrictions?: Restrictions; + readonly units: TestletType; + readonly customTexts: { [p: string]: string }; +} + +export interface BookletMetadata { + readonly id: string; + readonly label: string; + readonly description: string; +} + +export interface TestletDef { + readonly id: string; + readonly label: string; + readonly restrictions?: Restrictions; + readonly children: (TestletType | UnitType)[]; +} + +export interface UnitDef { + readonly id: string; + readonly label: string; + readonly labelShort: string; +} + +export interface BlockCondition { + readonly source: BlockConditionSource; + readonly expression: BlockConditionExpression; +} + +export interface BlockConditionSource { + readonly variable: string; + readonly unitAlias: string; +} + +export interface BlockConditionAggregation { + readonly type: 'count'; + readonly conditions: BlockCondition[]; +} + +export interface BlockConditionExpressionAggregation { + readonly type: 'sum' | 'median'; + readonly expressions: BlockConditionExpression[]; +} + +export interface BlockConditionExpression { + equal?: string; + notEqual?: string; + greaterThan?: number; + lowerThan?: number; +} + +export interface Restrictions { + codeToEnter?: { + readonly code: string; + readonly message: string; + }; + timeMax?: { + readonly minutes: number + }; + denyNavigationOnIncomplete?: { + readonly presentation: 'ON' | 'OFF' | 'ALWAYS'; + readonly response: 'ON' | 'OFF' | 'ALWAYS'; + } + readonly if?: Array +} + +export interface ContextInBooklet { + parent: TestletType | null; + globalIndex: number; + localIndex: number; + localIndexOfTestlets: number; +} diff --git a/frontend/src/app/shared/services/bookletParser.service.ts b/frontend/src/app/shared/services/bookletParser.service.ts new file mode 100644 index 000000000..850e45715 --- /dev/null +++ b/frontend/src/app/shared/services/bookletParser.service.ts @@ -0,0 +1,177 @@ +import { BookletConfig } from '../classes/booklet-config.class'; +import { + BookletDef, BookletMetadata, ContextInBooklet, Restrictions, TestletDef, UnitDef +} from '../interfaces/booklet.interfaces'; +import { AppError } from '../../app.interfaces'; + +export abstract class BookletParserService< + Unit extends UnitDef, + Testlet extends TestletDef, + Booklet extends BookletDef +> { + abstract toBooklet( + bookletDef: BookletDef, + bookletElement: Element + ): Booklet; + + abstract toTestlet( + testletDef: TestletDef, + testletElement: Element, + context: ContextInBooklet + ): Testlet; + + abstract toUnit( + unitDef: UnitDef, + unitElement: Element, + context: ContextInBooklet + ): Unit; + + parseBookletXml(xmlString: string): Booklet { + const domParser = new DOMParser(); + const xmlStringWithOutBom = xmlString.replace(/^\uFEFF/gm, ''); + const bookletElement = domParser.parseFromString(xmlStringWithOutBom, 'text/xml').documentElement; + + if (bookletElement.nodeName !== 'Booklet') { + throw new AppError({ label: 'Invalid XML', description: 'wrong root-tag', type: 'xml' }); + } + + const units = this.xmlGetChildIfExists(bookletElement, 'Units'); + if (units == null) { + throw new AppError({ label: 'Invalid XML', description: 'no ', type: 'xml' }); + } + + const metadata = this.parseMetadata(bookletElement); + if (metadata == null) { + throw new AppError({ label: 'Invalid XML', description: 'invalid metadata', type: 'xml' }); + } + + const rootContext: ContextInBooklet = { + globalIndex: 0, + localIndex: 0, + localIndexOfTestlets: 0, + parent: null + }; + return this.toBooklet( + { + units: this.parseTestlet(units, rootContext), + metadata: metadata, + config: this.parseBookletConfig(bookletElement), + customTexts: this.xmlGetCustomTexts(bookletElement) + }, + bookletElement + ); + } + + parseBookletConfig(bookletElement: Element): BookletConfig { + const bookletConfigElements = this.xmlGetChildIfExists(bookletElement, 'BookletConfig', true); + const bookletConfig = new BookletConfig(); + if (bookletConfigElements) { + bookletConfig.setFromXml(bookletConfigElements); + } + return bookletConfig; + } + + parseMetadata(bookletElement: Element): BookletMetadata | null { + const metadataElement = this.xmlGetChildIfExists(bookletElement, 'Metadata'); + if (!metadataElement) { + return null; + } + return { + id: this.xmlGetChildTextIfExists(metadataElement, 'Id'), + label: this.xmlGetChildTextIfExists(metadataElement, 'Label'), + description: this.xmlGetChildTextIfExists(metadataElement, 'Description', true) + }; + } + + private parseTestlet(testletElement: Element, context: ContextInBooklet): Testlet { + let testletChildrenCount = 0; + const pointerContainer: { self: Testlet | null } = { self: null }; + const testlet = this.toTestlet( + { + id: testletElement.getAttribute('id') || '', + label: testletElement.getAttribute('label') || '', + restrictions: this.parseRestrictions(testletElement), + children: this.xmlGetDirectChildrenByTagName(testletElement, ['Unit', 'Testlet']) + .map((item, index) => this.parseUnitOrTestlet(item, { + localIndex: index, + globalIndex: context.localIndex + index, + // eslint-disable-next-line no-plusplus + localIndexOfTestlets: (item.tagName === 'Testlet') ? testletChildrenCount++ : NaN, + parent: pointerContainer.self + })) + }, + testletElement, + context + ); + pointerContainer.self = testlet; + return testlet; + } + + parseUnitOrTestlet(element: Element, context: ContextInBooklet): (Unit | Testlet) { + if (element.tagName === 'Unit') { + return this.toUnit( + { + id: element.getAttribute('alias') || element.getAttribute('id') || '', + label: element.getAttribute('label') || '', + labelShort: element.getAttribute('labelshort') || '' + }, + element, + context + ); + } + return this.parseTestlet(element, context); + } + + parseRestrictions(testletElement: Element): Restrictions { + const restrictions: Restrictions = {}; + const restrictionsElement = this.xmlGetChildIfExists(testletElement, 'Restrictions', true); + if (!restrictionsElement) { + return restrictions; + } + const codeToEnterElement = restrictionsElement.querySelector('CodeToEnter'); + if (codeToEnterElement) { + restrictions.codeToEnter = { + code: codeToEnterElement.getAttribute('code') || '', + message: codeToEnterElement.textContent || '' + }; + } + const timeMaxElement = restrictionsElement.querySelector('TimeMax'); + if (timeMaxElement) { + restrictions.timeMax = { + minutes: parseFloat(timeMaxElement.getAttribute('minutes') || '') + }; + } + return restrictions; + } + + xmlGetChildIfExists(element: Element, childName: string, isOptional = false): Element | null { + const elements = this.xmlGetDirectChildrenByTagName(element, [childName]); + if (!elements.length && !isOptional) { + throw new Error(`Missing field: '${childName}'`); + } + return elements.length ? elements[0] : null; + } + + xmlGetChildTextIfExists(element: Element, childName: string, isOptional = false): string { + const childElement = this.xmlGetChildIfExists(element, childName, isOptional); + return (childElement && childElement.textContent) ? childElement.textContent : ''; + } + + // eslint-disable-next-line class-methods-use-this + xmlGetDirectChildrenByTagName(element: Element, tagNames: string[]): Element[] { + return [].slice.call(element.childNodes) + .filter((elem: Element) => (elem.nodeType === 1)) + .filter((elem: Element) => (tagNames.indexOf(elem.tagName) > -1)); + } + + // eslint-disable-next-line class-methods-use-this + xmlCountChildrenOfTagNames(element: Element, tagNames: string[]): number { + return element.querySelectorAll(tagNames.join(', ')).length; + } + + // eslint-disable-next-line class-methods-use-this + xmlGetCustomTexts(element: Element): { [key: string]: string } { + // TODO X + return {}; + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index d87e3e519..18a54a7e5 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -71,3 +71,4 @@ export { SysConfig, AppSettings } from './interfaces/app-config.interfaces'; export { BookletConfig } from './classes/booklet-config.class'; export { TestMode } from './classes/test-mode.class'; export { customTextDefaults } from './objects/customTextDefaults'; +export * from './interfaces/booklet.interfaces'; diff --git a/frontend/src/app/test-controller/classes/test-controller.classes.ts b/frontend/src/app/test-controller/classes/test-controller.classes.ts index 966af2656..2dd536451 100644 --- a/frontend/src/app/test-controller/classes/test-controller.classes.ts +++ b/frontend/src/app/test-controller/classes/test-controller.classes.ts @@ -2,6 +2,7 @@ import UAParser from 'ua-parser-js'; import { MaxTimerDataType, NavigationLeaveRestrictionValue } from '../interfaces/test-controller.interfaces'; +import { Restrictions } from '../../shared/interfaces/booklet.interfaces'; export class TestletContentElement { readonly sequenceId: number; @@ -28,230 +29,233 @@ export class TestletContentElement { } } -export class UnitDef extends TestletContentElement { - readonly alias: string; - readonly naviButtonLabel: string; - playerFileName: string = ''; - lockedByTime = false; - readonly navigationLeaveRestrictions: NavigationLeaveRestrictions; +// export class UnitDef extends TestletContentElement { +// readonly alias: string; +// readonly naviButtonLabel: string; +// playerFileName: string = ''; +// lockedByTime = false; +// readonly restrictions: Restrictions; +// +// constructor( +// sequenceId: number, +// id: string, +// title: string, +// alias: string, +// naviButtonLabel: string, +// restrictions: Restrictions +// ) { +// super(sequenceId, id, title); +// this.alias = alias; +// this.naviButtonLabel = naviButtonLabel; +// this.restrictions = restrictions; +// } +// } - constructor( - sequenceId: number, - id: string, - title: string, - alias: string, - naviButtonLabel: string, - navigationLeaveRestrictions: NavigationLeaveRestrictions - ) { - super(sequenceId, id, title); - this.alias = alias; - this.naviButtonLabel = naviButtonLabel; - this.navigationLeaveRestrictions = navigationLeaveRestrictions; - } -} - -export class UnitWithContext { - unitDef: UnitDef; - codeRequiringTestlets: Testlet[] = []; - maxTimerRequiringTestlet: Testlet | null = null; - testletLabel = ''; - constructor(unitDef: UnitDef) { - this.unitDef = unitDef; - } -} - -export class NavigationLeaveRestrictions { - readonly presentationComplete: NavigationLeaveRestrictionValue = 'OFF'; - readonly responseComplete: NavigationLeaveRestrictionValue = 'OFF'; - - constructor(presentationComplete: NavigationLeaveRestrictionValue, - responseComplete: NavigationLeaveRestrictionValue) { - this.presentationComplete = presentationComplete; - this.responseComplete = responseComplete; - } -} - -export class Testlet extends TestletContentElement { - codeToEnter = ''; - codePrompt = ''; - maxTimeLeft = -1; - - addTestlet(id: string, title: string): Testlet { - const newChild = new Testlet(0, id, title); - this.children.push(newChild); - return newChild; - } - - addUnit( - sequenceId: number, - id: string, - title: string, - alias: string, - naviButtonLabel: string, - navigationLeaveRestrictions: NavigationLeaveRestrictions - ): UnitDef { - const newChild = new UnitDef(sequenceId, id, title, alias, naviButtonLabel, navigationLeaveRestrictions); - this.children.push(newChild); - return newChild; - } - - // first looking for the unit, then on the way back adding restrictions - // TODO this very ineffective function is called quite often, so ... - // ...instead of enrich the unit with the parental data, collect it beforehand - getUnitAt(sequenceId: number, isEntryPoint = true): UnitWithContext | null { - let myreturn: UnitWithContext | null = null; - for (let i = 0; i < this.children.length; i++) { - const tce = this.children[i]; - if (tce instanceof Testlet) { - const localTestlet = tce as Testlet; - myreturn = localTestlet.getUnitAt(sequenceId, false); - if (myreturn !== null) { - break; - } - } else if (tce instanceof UnitDef) { - if (tce.sequenceId === sequenceId) { - myreturn = new UnitWithContext(tce); - break; - } - } - } - if (myreturn !== null) { - if (this.codeToEnter.length > 0) { - myreturn.codeRequiringTestlets.push(this); - } - if (this.maxTimeLeft > 0) { - myreturn.maxTimerRequiringTestlet = this; - } - if (!isEntryPoint) { - const label = this.title.trim(); - if (label) { - myreturn.testletLabel = label; - } - } - } - return myreturn; - } +// export class UnitWithContext { +// unitDef: UnitDef; +// codeRequiringTestlets: Testlet[] = []; +// maxTimerRequiringTestlet: Testlet | null = null; +// testletLabel = ''; +// constructor(unitDef: UnitDef) { +// this.unitDef = unitDef; +// } +// } - getSequenceIdByUnitAlias(alias: string): number { - for (let i = 0; i < this.children.length; i++) { - const child = this.children[i]; - if (child instanceof UnitDef) { - if (child.alias === alias) { - return child.sequenceId; - } - } - if (child instanceof Testlet) { - const sequenceId = child.getSequenceIdByUnitAlias(alias); - if (sequenceId) { - return sequenceId; - } - } - } - return 0; - } - - getTestlet(testletId: string): Testlet | null { - let myreturn: Testlet | null = null; - if (this.id === testletId) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - myreturn = this; - } else { - for (let i = 0; i < this.children.length; i++) { - const tce = this.children[i]; - if (tce instanceof Testlet) { - const localTestlet = tce as Testlet; - myreturn = localTestlet.getTestlet(testletId); - if (myreturn !== null) { - break; - } - } - } - } - return myreturn; - } - - getAllUnitSequenceIds(testletId = ''): number[] { - let myreturn: number[] = []; - - if (testletId) { - // find testlet - const myTestlet = this.getTestlet(testletId); - if (myTestlet) { - myreturn = myTestlet.getAllUnitSequenceIds(); - } - } else { - for (let i = 0; i < this.children.length; i++) { - const tce = this.children[i]; - if (tce instanceof Testlet) { - const localTestlet = tce as Testlet; - localTestlet.getAllUnitSequenceIds().forEach(u => myreturn.push(u)); - } else { - const localUnit = tce as UnitDef; - myreturn.push(localUnit.sequenceId); - } - } - } - return myreturn; - } +// export class NavigationLeaveRestrictions { +// readonly presentationComplete: NavigationLeaveRestrictionValue = 'OFF'; +// readonly responseComplete: NavigationLeaveRestrictionValue = 'OFF'; +// +// constructor(presentationComplete: NavigationLeaveRestrictionValue, +// responseComplete: NavigationLeaveRestrictionValue) { +// this.presentationComplete = presentationComplete; +// this.responseComplete = responseComplete; +// } +// } - // TODO make this function obsolete. maxTimeLeft should never be changed, use tcs.maxTimeTimers instead - setTimeLeft(testletId: string, maxTimeLeft: number): void { - // attention, it's absurd: if you want to setTime of this use testlet.setTime(testelt.id, time)... - if (testletId) { - // find testlet - const testlet = this.getTestlet(testletId); - if (testlet) { - testlet.setTimeLeft('', maxTimeLeft); - if (maxTimeLeft === 0) { - testlet.lockAllChildren(); - } - } - } else { - this.maxTimeLeft = maxTimeLeft; - for (let i = 0; i < this.children.length; i++) { - const tce = this.children[i]; - if (tce instanceof Testlet) { - tce.setTimeLeft('', maxTimeLeft); - } - } - } - } - - lockAllChildren(testletId = ''): void { - if (testletId) { - const testlet = this.getTestlet(testletId); - if (testlet) { - testlet.lockAllChildren(); - } - } else { - for (let i = 0; i < this.children.length; i++) { - const tce = this.children[i]; - if (tce instanceof Testlet) { - const localTestlet = tce as Testlet; - localTestlet.lockAllChildren(); - } else { - const localUnit = tce as UnitDef; - localUnit.lockedByTime = true; - } - } - } - } - - lockUnitsIfTimeLeftNull(lock = false): void { - // eslint-disable-next-line no-param-reassign - lock = lock || this.maxTimeLeft === 0; - for (let i = 0; i < this.children.length; i++) { - const tce = this.children[i]; - if (tce instanceof Testlet) { - const localTestlet = tce as Testlet; - localTestlet.lockUnitsIfTimeLeftNull(lock); - } else if (lock) { - const localUnit = tce as UnitDef; - localUnit.lockedByTime = true; - } - } - } -} +// export class Testlet extends TestletContentElement { +// readonly restrictions: Restrictions = { +// denyNavigationOnIncomplete: { +// response: 'OFF', +// presentation: 'OFF' +// } +// }; +// +// addTestlet(id: string, title: string): Testlet { +// const newChild = new Testlet(0, id, title); +// this.children.push(newChild); +// return newChild; +// } +// +// addUnit( +// sequenceId: number, +// id: string, +// title: string, +// alias: string, +// naviButtonLabel: string, +// restrictions: Restrictions +// ): UnitDef { +// const newChild = new UnitDef(sequenceId, id, title, alias, naviButtonLabel, restrictions); +// this.children.push(newChild); +// return newChild; +// } +// +// // first looking for the unit, then on the way back adding restrictions +// // TODO this very ineffective function is called quite often, so ... +// // ...instead of enrich the unit with the parental data, collect it beforehand +// getUnitAt(sequenceId: number, isEntryPoint = true): UnitWithContext | null { +// let myreturn: UnitWithContext | null = null; +// for (let i = 0; i < this.children.length; i++) { +// const tce = this.children[i]; +// if (tce instanceof Testlet) { +// const localTestlet = tce as Testlet; +// myreturn = localTestlet.getUnitAt(sequenceId, false); +// if (myreturn !== null) { +// break; +// } +// } else if (tce instanceof UnitDef) { +// if (tce.sequenceId === sequenceId) { +// myreturn = new UnitWithContext(tce); +// break; +// } +// } +// } +// if (myreturn !== null) { +// if (this.restrictions.codeToEnter?.code) { +// myreturn.codeRequiringTestlets.push(this); +// } +// if (this.restrictions.timeMax?.minutes) { +// myreturn.maxTimerRequiringTestlet = this; +// } +// if (!isEntryPoint) { +// const label = this.title.trim(); +// if (label) { +// myreturn.testletLabel = label; +// } +// } +// } +// return myreturn; +// } +// +// getSequenceIdByUnitAlias(alias: string): number { +// for (let i = 0; i < this.children.length; i++) { +// const child = this.children[i]; +// if (child instanceof UnitDef) { +// if (child.alias === alias) { +// return child.sequenceId; +// } +// } +// if (child instanceof Testlet) { +// const sequenceId = child.getSequenceIdByUnitAlias(alias); +// if (sequenceId) { +// return sequenceId; +// } +// } +// } +// return 0; +// } +// +// getTestlet(testletId: string): Testlet | null { +// let myreturn: Testlet | null = null; +// if (this.id === testletId) { +// // eslint-disable-next-line @typescript-eslint/no-this-alias +// myreturn = this; +// } else { +// for (let i = 0; i < this.children.length; i++) { +// const tce = this.children[i]; +// if (tce instanceof Testlet) { +// const localTestlet = tce as Testlet; +// myreturn = localTestlet.getTestlet(testletId); +// if (myreturn !== null) { +// break; +// } +// } +// } +// } +// return myreturn; +// } +// +// getAllUnitSequenceIds(testletId = ''): number[] { +// let myreturn: number[] = []; +// +// if (testletId) { +// // find testlet +// const myTestlet = this.getTestlet(testletId); +// if (myTestlet) { +// myreturn = myTestlet.getAllUnitSequenceIds(); +// } +// } else { +// for (let i = 0; i < this.children.length; i++) { +// const tce = this.children[i]; +// if (tce instanceof Testlet) { +// const localTestlet = tce as Testlet; +// localTestlet.getAllUnitSequenceIds().forEach(u => myreturn.push(u)); +// } else { +// const localUnit = tce as UnitDef; +// myreturn.push(localUnit.sequenceId); +// } +// } +// } +// return myreturn; +// } +// +// // TODO make this function obsolete. maxTimeLeft should never be changed, use tcs.maxTimeTimers instead +// // setTimeLeft(testletId: string, maxTimeLeft: number): void { +// // // attention, it's absurd: if you want to setTime of this use testlet.setTime(testelt.id, time)... +// // if (testletId) { +// // // find testlet +// // const testlet = this.getTestlet(testletId); +// // if (testlet) { +// // testlet.setTimeLeft('', maxTimeLeft); +// // if (maxTimeLeft === 0) { +// // testlet.lockAllChildren(); +// // } +// // } +// // } else { +// // this.maxTimeLeft = maxTimeLeft; +// // for (let i = 0; i < this.children.length; i++) { +// // const tce = this.children[i]; +// // if (tce instanceof Testlet) { +// // tce.setTimeLeft('', maxTimeLeft); +// // } +// // } +// // } +// // } +// +// lockAllChildren(testletId = ''): void { +// if (testletId) { +// const testlet = this.getTestlet(testletId); +// if (testlet) { +// testlet.lockAllChildren(); +// } +// } else { +// for (let i = 0; i < this.children.length; i++) { +// const tce = this.children[i]; +// if (tce instanceof Testlet) { +// const localTestlet = tce as Testlet; +// localTestlet.lockAllChildren(); +// } else { +// const localUnit = tce as UnitDef; +// localUnit.lockedByTime = true; +// } +// } +// } +// } +// +// // lockUnitsIfTimeLeftNull(lock = false): void { +// // // eslint-disable-next-line no-param-reassign +// // lock = lock || this.maxTimeLeft === 0; +// // for (let i = 0; i < this.children.length; i++) { +// // const tce = this.children[i]; +// // if (tce instanceof Testlet) { +// // const localTestlet = tce as Testlet; +// // localTestlet.lockUnitsIfTimeLeftNull(lock); +// // } else if (lock) { +// // const localUnit = tce as UnitDef; +// // localUnit.lockedByTime = true; +// // } +// // } +// // } +// } export class EnvironmentData { browserVersion = ''; diff --git a/frontend/src/app/test-controller/components/test-controller/test-controller.component.ts b/frontend/src/app/test-controller/components/test-controller/test-controller.component.ts index f7159958e..e0d8e96c9 100644 --- a/frontend/src/app/test-controller/components/test-controller/test-controller.component.ts +++ b/frontend/src/app/test-controller/components/test-controller/test-controller.component.ts @@ -184,7 +184,7 @@ export class TestControllerComponent implements OnInit, OnDestroy { showReviewDialog(): void { const authData = this.mainDataService.getAuthData(); - if (this.tcs.rootTestlet === null) { + if (this.tcs.booklet === null) { this.snackBar.open('Kein Testheft verfügbar.', '', { duration: 5000 }); } else if (!authData) { throw new AppError({ description: '', label: 'Nicht Angemeldet!' }); @@ -192,7 +192,7 @@ export class TestControllerComponent implements OnInit, OnDestroy { const dialogRef = this.reviewDialog.open(ReviewDialogComponent, { data: { loginname: authData.displayName, - bookletname: this.tcs.rootTestlet.title, + bookletname: this.tcs.booklet.metadata.label, unitTitle: this.tcs.currentUnitTitle, unitDbKey: this.tcs.currentUnitDbKey } @@ -226,7 +226,7 @@ export class TestControllerComponent implements OnInit, OnDestroy { } break; case 'destroy': - this.tcs.rootTestlet = null; + this.tcs.booklet = null; break; case 'pause': this.tcs.resumeTargetUnitSequenceId = this.tcs.currentUnitSequenceId; @@ -256,7 +256,7 @@ export class TestControllerComponent implements OnInit, OnDestroy { if (gotoTarget && gotoTarget !== '0') { this.tcs.resumeTargetUnitSequenceId = 0; this.tcs.cancelMaxTimer(); - const targetUnit = this.tcs.getUnitWithContext(parseInt(gotoTarget, 10)); + const targetUnit = this.tcs.getUnit(parseInt(gotoTarget, 10)); if (targetUnit) { targetUnit.codeRequiringTestlets .forEach(testlet => { @@ -272,7 +272,7 @@ export class TestControllerComponent implements OnInit, OnDestroy { } private handleMaxTimer(maxTimerData: MaxTimerData): void { - if (!this.tcs.rootTestlet) { + if (!this.tcs.booklet) { throw new AppError({ description: '', label: 'Roottestlet used to early' }); } const minute = maxTimerData.timeLeftSeconds / 60; @@ -297,14 +297,14 @@ export class TestControllerComponent implements OnInit, OnDestroy { } this.timerValue = null; if (this.tcs.testMode.forceTimeRestrictions) { - this.tcs.rootTestlet.setTimeLeft(maxTimerData.testletId, 0); + this.tcs.setTimeLeft(maxTimerData.testletId, 0); const nextUnlockedUSId = this.tcs.getNextUnlockedUnitSequenceId(this.tcs.currentUnitSequenceId); this.tcs.setUnitNavigationRequest(nextUnlockedUSId?.toString(10) ?? UnitNavigationTarget.END, true); } break; case MaxTimerDataType.CANCELLED: this.snackBar.open(this.cts.getCustomText('booklet_msgTimerCancelled'), '', { duration: 5000 }); - this.tcs.rootTestlet.setTimeLeft(maxTimerData.testletId, 0); + this.tcs.setTimeLeft(maxTimerData.testletId, 0); this.tcs.maxTimeTimers[maxTimerData.testletId] = 0; if (this.tcs.testMode.saveResponses) { this.bs.updateTestState( @@ -348,15 +348,15 @@ export class TestControllerComponent implements OnInit, OnDestroy { private refreshUnitMenu(): void { this.unitNavigationList = []; - if (!this.tcs.rootTestlet) { + if (!this.tcs.booklet) { return; } let previousBlockLabel: string | null = null; - const unitCount = this.tcs.rootTestlet.getMaxSequenceId() - 1; + const unitCount = this.tcs.getMaxSequenceId() - 1; for (let sequenceId = 1; sequenceId <= unitCount; sequenceId++) { - const unitData = this.tcs.getUnitWithContext(sequenceId); + const unit = this.tcs.getUnit(sequenceId); - const blockLabel = unitData.testletLabel || ''; + const blockLabel = unit.testletLabel || ''; if ((previousBlockLabel != null) && (blockLabel !== previousBlockLabel)) { this.unitNavigationList.push(blockLabel); } @@ -364,29 +364,29 @@ export class TestControllerComponent implements OnInit, OnDestroy { this.unitNavigationList.push({ sequenceId, - shortLabel: unitData.unitDef.naviButtonLabel, - longLabel: unitData.unitDef.title, - testletLabel: unitData.testletLabel, - disabled: this.tcs.getUnitIsLocked(unitData), + shortLabel: unit.labelShort, + longLabel: unit.label, + testletLabel: unit.testletLabel, + disabled: this.tcs.getUnitIsLocked(unit), isCurrent: sequenceId === this.tcs.currentUnitSequenceId }); } } private setUnitScreenHeader(): void { - if (!this.tcs.rootTestlet || !this.tcs.currentUnitSequenceId) { + if (!this.tcs.booklet || !this.tcs.currentUnitSequenceId) { this.unitScreenHeader = ''; return; } switch (this.tcs.bookletConfig.unit_screenheader) { case 'WITH_UNIT_TITLE': - this.unitScreenHeader = this.tcs.getUnitWithContext(this.tcs.currentUnitSequenceId).unitDef.title; + this.unitScreenHeader = this.tcs.getUnit(this.tcs.currentUnitSequenceId).label; break; case 'WITH_BOOKLET_TITLE': - this.unitScreenHeader = this.tcs.rootTestlet.title; + this.unitScreenHeader = this.tcs.booklet.metadata.label; break; case 'WITH_BLOCK_TITLE': - this.unitScreenHeader = this.tcs.getUnitWithContext(this.tcs.currentUnitSequenceId).testletLabel; + this.unitScreenHeader = this.tcs.getUnit(this.tcs.currentUnitSequenceId).testletLabel; break; default: this.unitScreenHeader = ''; diff --git a/frontend/src/app/test-controller/components/unithost/unithost.component.html b/frontend/src/app/test-controller/components/unithost/unithost.component.html index d783d311e..0ddb022d9 100644 --- a/frontend/src/app/test-controller/components/unithost/unithost.component.html +++ b/frontend/src/app/test-controller/components/unithost/unithost.component.html @@ -2,7 +2,7 @@ 'with-header': tcs.bookletConfig.unit_screenheader !== 'OFF', 'with-title': tcs.bookletConfig.unit_title === 'ON', 'with-footer': tcs.bookletConfig.page_navibuttons === 'SEPARATE_BOTTOM', - 'is-waiting': currentUnit?.unitDef?.locked || (unitsLoading$ | async).length || codeRequiringTestlets.length}"> + 'is-waiting': currentUnit?.lockedByTime || (unitsLoading$ | async).length || codeRequiringTestlets.length}">

{{currentUnit?.unitDef?.title}}

@@ -42,19 +42,19 @@

{{currentUnit?.unitDef?.title}}

style="text-transform:uppercase" (keydown)="onKeydownInClearCodeInput($event)" data-cy="unlockUnit" - matTooltip="{{codeRequiringTestlets.length > 1 ? testlet.title || ('Block ' + (testlet.sequenceId + 1)) : undefined}}" + matTooltip="{{codeRequiringTestlets.length > 1 ? testlet.label || ('Block ' + (testlet.sequenceId + 1)) : undefined}}" > diff --git a/frontend/src/app/test-controller/components/unithost/unithost.component.ts b/frontend/src/app/test-controller/components/unithost/unithost.component.ts index 7faf3a1cb..1c2092d69 100644 --- a/frontend/src/app/test-controller/components/unithost/unithost.component.ts +++ b/frontend/src/app/test-controller/components/unithost/unithost.component.ts @@ -11,8 +11,8 @@ import { WindowFocusState, PendingUnitData, StateReportEntry, - UnitStateKey, - UnitPlayerState, LoadingProgress, UnitNavigationTarget + UnitStateKey, Testlet, + UnitPlayerState, LoadingProgress, UnitNavigationTarget, Unit } from '../../interfaces/test-controller.interfaces'; import { BackendService } from '../../services/backend.service'; import { TestControllerService } from '../../services/test-controller.service'; @@ -20,7 +20,6 @@ import { MainDataService } from '../../../shared/shared.module'; import { VeronaNavigationDeniedReason, VeronaNavigationTarget, VeronaPlayerConfig, VeronaProgress } from '../../interfaces/verona.interfaces'; -import { Testlet, UnitWithContext } from '../../classes/test-controller.classes'; import { AppError } from '../../../app.interfaces'; @Component({ @@ -44,7 +43,7 @@ export class UnithostComponent implements OnInit, OnDestroy { unitsLoading$: BehaviorSubject = new BehaviorSubject([]); unitsToLoadLabels: string[] = []; - currentUnit: UnitWithContext | null = null; + currentUnit: Unit | null = null; currentPageIndex: number = -1; unitNavigationTarget = UnitNavigationTarget; @@ -108,7 +107,7 @@ export class UnithostComponent implements OnInit, OnDestroy { this.tcs.updateUnitState( this.currentUnitSequenceId, { - unitDbKey: this.currentUnit.unitDef.alias, + unitDbKey: this.currentUnit.id, // TODO x alias? state: [{ key: UnitStateKey.PLAYER, timeStamp: Date.now(), @@ -151,7 +150,7 @@ export class UnithostComponent implements OnInit, OnDestroy { this.tcs.updateUnitState( this.currentUnitSequenceId, { - unitDbKey: this.currentUnit.unitDef.alias, + unitDbKey: this.currentUnit.id, // TODO X alias? state: [ { key: UnitStateKey.CURRENT_PAGE_NR, timeStamp: Date.now(), content: pageNr.toString() }, { key: UnitStateKey.CURRENT_PAGE_ID, timeStamp: Date.now(), content: pageId }, @@ -162,7 +161,7 @@ export class UnithostComponent implements OnInit, OnDestroy { } } } - const unitDbKey = this.currentUnit.unitDef.alias; + const unitDbKey = this.currentUnit.id; // TODO X alias? if (msgData.unitState) { const { unitState } = msgData; const timeStamp = Date.now(); @@ -227,7 +226,7 @@ export class UnithostComponent implements OnInit, OnDestroy { } private open(currentUnitSequenceId: number): void { - if (!this.tcs.rootTestlet) { + if (!this.tcs.booklet) { throw new Error('Booklet not loaded'); } this.currentUnitSequenceId = currentUnitSequenceId; @@ -240,30 +239,32 @@ export class UnithostComponent implements OnInit, OnDestroy { this.currentPageIndex = -1; this.knownPages = []; - this.currentUnit = this.tcs.getUnitWithContext(this.currentUnitSequenceId); + this.currentUnit = this.tcs.getUnit(this.currentUnitSequenceId); - this.mainDataService.appSubTitle$.next(this.currentUnit.unitDef.title); + this.mainDataService.appSubTitle$.next(this.currentUnit.label); if (this.subscriptions.loading) { this.subscriptions.loading.unsubscribe(); } const unitsToLoadIds = this.currentUnit.maxTimerRequiringTestlet ? - this.tcs.rootTestlet.getAllUnitSequenceIds(this.currentUnit.maxTimerRequiringTestlet.id) : + this.tcs.getAllUnitSequenceIds(this.currentUnit.maxTimerRequiringTestlet.id) : [currentUnitSequenceId]; const unitsToLoad = unitsToLoadIds .map(unitSequenceId => this.tcs.getUnitLoadProgress$(unitSequenceId)); this.unitsToLoadLabels = unitsToLoadIds - .map(unitSequenceId => this.tcs.getUnitWithContext(unitSequenceId).unitDef.title); + .map(unitSequenceId => this.tcs.getUnit(unitSequenceId).label); + console.log(unitsToLoad); this.subscriptions.loading = combineLatest(unitsToLoad) .subscribe({ next: value => { this.unitsLoading$.next(value); }, error: err => { + console.log(err); this.mainDataService.appError = new AppError({ label: `Unit konnte nicht geladen werden. ${err.info}`, description: (err.info) ? err.info : err, @@ -279,17 +280,17 @@ export class UnithostComponent implements OnInit, OnDestroy { throw new Error('Unit not loaded'); } this.unitsLoading$.next([]); - this.tcs.currentUnitDbKey = this.currentUnit.unitDef.alias; - this.tcs.currentUnitTitle = this.currentUnit.unitDef.title; + this.tcs.currentUnitDbKey = this.currentUnit.id; // TODO X alias? + this.tcs.currentUnitTitle = this.currentUnit.label; if (this.tcs.testMode.saveResponses) { this.bs.updateTestState(this.tcs.testId, [{ - key: TestStateKey.CURRENT_UNIT_ID, timeStamp: Date.now(), content: this.currentUnit.unitDef.alias + key: TestStateKey.CURRENT_UNIT_ID, timeStamp: Date.now(), content: this.tcs.currentUnitDbKey }]); this.tcs.updateUnitState( this.currentUnitSequenceId, { - unitDbKey: this.currentUnit.unitDef.alias, + unitDbKey: this.tcs.currentUnitDbKey, state: [{ key: UnitStateKey.PLAYER, timeStamp: Date.now(), content: UnitPlayerState.LOADING }] } ); @@ -297,7 +298,7 @@ export class UnithostComponent implements OnInit, OnDestroy { if (this.tcs.testMode.presetCode) { this.currentUnit.codeRequiringTestlets - .forEach(testlet => { this.clearCodes[testlet.id] = testlet.codeToEnter; }); + .forEach(testlet => { this.clearCodes[testlet.id] = testlet.restrictions?.codeToEnter?.code || ''; }); } this.runUnit(); @@ -313,7 +314,7 @@ export class UnithostComponent implements OnInit, OnDestroy { return; } - if (this.currentUnit.unitDef.lockedByTime) { + if (this.currentUnit.lockedByTime) { return; } @@ -366,7 +367,7 @@ export class UnithostComponent implements OnInit, OnDestroy { this.iFrameItemplayer.setAttribute('class', 'unitHost'); this.adjustIframeSize(); this.iFrameHostElement.nativeElement.appendChild(this.iFrameItemplayer); - this.iFrameItemplayer.setAttribute('srcdoc', this.tcs.getPlayer(this.currentUnit.unitDef.playerFileName)); + this.iFrameItemplayer.setAttribute('srcdoc', this.tcs.getPlayer(this.currentUnit.playerFileName)); } private adjustIframeSize(): void { @@ -404,7 +405,7 @@ export class UnithostComponent implements OnInit, OnDestroy { pagingMode: this.tcs.bookletConfig.pagingMode, unitNumber: this.currentUnitSequenceId, unitTitle: this.tcs.currentUnitTitle, - unitId: this.currentUnit.unitDef.alias, + unitId: this.currentUnit.id, // TODO X alias? directDownloadUrl: `${resourceUri}file/${groupToken}/ws_${this.tcs.workspaceId}/Resource` }; if (this.pendingUnitData?.currentPage && (this.tcs.bookletConfig.restore_current_page_on_return === 'ON')) { @@ -476,12 +477,14 @@ export class UnithostComponent implements OnInit, OnDestroy { if (!this.clearCodes[testlet.id]) { return; } - if (testlet.codeToEnter.toUpperCase().trim() === this.clearCodes[testlet.id].toUpperCase().trim()) { + const requiredCode = (testlet.restrictions?.codeToEnter?.code || '').toUpperCase().trim(); + const givenCode = this.clearCodes[testlet.id].toUpperCase().trim(); + if (requiredCode === givenCode) { this.tcs.addClearedCodeTestlet(testlet.id); this.runUnit(); } else { this.snackBar.open( - `Freigabewort '${this.clearCodes[testlet.id]}' für '${testlet.title}' stimmt nicht.`, + `Freigabewort '${givenCode}' für '${testlet.label}' stimmt nicht.`, 'OK', { duration: 3000 } ); diff --git a/frontend/src/app/test-controller/interfaces/test-controller.interfaces.ts b/frontend/src/app/test-controller/interfaces/test-controller.interfaces.ts index 74f2cfdf6..bc976d373 100644 --- a/frontend/src/app/test-controller/interfaces/test-controller.interfaces.ts +++ b/frontend/src/app/test-controller/interfaces/test-controller.interfaces.ts @@ -1,5 +1,6 @@ // used everywhere import { VeronaProgress } from './verona.interfaces'; +import { BookletDef, TestletDef, UnitDef } from '../../shared/interfaces/booklet.interfaces'; export interface LoadingQueueEntry { sequenceId: number; @@ -225,3 +226,27 @@ export type LoadingFile = LoadingProgress | LoadedFile; export function isLoadingFileLoaded(loadingFile: LoadingFile): loadingFile is LoadedFile { return 'content' in loadingFile; } + +export interface Unit extends UnitDef { + readonly sequenceId: number; + readonly parent: Testlet | null; + readonly codeRequiringTestlets: Testlet[]; + readonly maxTimerRequiringTestlet: Testlet | null; + readonly testletLabel: string; + lockedByTime: boolean; + playerFileName: string; +} + +export interface Testlet extends TestletDef { + readonly sequenceId: number; +} + +export type Booklet = BookletDef; + +export function isUnit(testletOrUnit: Testlet | Unit): testletOrUnit is Unit { + return !('children' in testletOrUnit); +} + +export function isTestlet(testletOrUnit: Testlet | Unit): testletOrUnit is Testlet { + return !isUnit(testletOrUnit); +} \ No newline at end of file diff --git a/frontend/src/app/test-controller/routing/unit-activate.guard.ts b/frontend/src/app/test-controller/routing/unit-activate.guard.ts index 914d82cc7..a682e91a9 100644 --- a/frontend/src/app/test-controller/routing/unit-activate.guard.ts +++ b/frontend/src/app/test-controller/routing/unit-activate.guard.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { Observable } from 'rxjs'; -import { UnitWithContext } from '../classes/test-controller.classes'; import { TestControllerService } from '../services/test-controller.service'; import { MessageService } from '../../shared/services/message.service'; @@ -15,7 +14,7 @@ export class UnitActivateGuard { canActivate(route: ActivatedRouteSnapshot): Observable | boolean { const targetUnitSequenceId: number = Number(route.params.u); - if (this.tcs.rootTestlet === null) { + if (this.tcs.booklet === null) { // unit-route got called before test is loaded. This happens on page-reload (F5). const testId = Number(route.parent?.params.t); if (!testId) { @@ -26,7 +25,7 @@ export class UnitActivateGuard { this.router.navigate([`/t/${testId}`]); return false; } - const newUnit: UnitWithContext = this.tcs.getUnitWithContext(targetUnitSequenceId); + const newUnit = this.tcs.getUnit(targetUnitSequenceId); if (!newUnit) { // a unit-nr was entered in the URl which does not exist this.messageService.showError(`Navigation zu Aufgabe ${targetUnitSequenceId} nicht möglich`); @@ -34,7 +33,7 @@ export class UnitActivateGuard { } if (this.tcs.getUnitIsLocked(newUnit)) { // a unitId of a locked unit was inserted - const previousUnlockedUnit = this.tcs.getNextUnlockedUnitSequenceId(newUnit.unitDef.sequenceId, true); + const previousUnlockedUnit = this.tcs.getNextUnlockedUnitSequenceId(newUnit.sequenceId, true); if (!previousUnlockedUnit) { return false; } diff --git a/frontend/src/app/test-controller/routing/unit-deactivate.guard.ts b/frontend/src/app/test-controller/routing/unit-deactivate.guard.ts index 3051e8cb1..5a1790ea8 100644 --- a/frontend/src/app/test-controller/routing/unit-deactivate.guard.ts +++ b/frontend/src/app/test-controller/routing/unit-deactivate.guard.ts @@ -7,8 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { ConfirmDialogComponent, ConfirmDialogData, CustomtextService } from '../../shared/shared.module'; -import { NavigationLeaveRestrictionValue, TestControllerState } from '../interfaces/test-controller.interfaces'; -import { UnitWithContext } from '../classes/test-controller.classes'; +import { NavigationLeaveRestrictionValue, TestControllerState, Unit } from '../interfaces/test-controller.interfaces'; import { UnithostComponent } from '../components/unithost/unithost.component'; import { TestControllerService } from '../services/test-controller.service'; import { VeronaNavigationDeniedReason } from '../interfaces/verona.interfaces'; @@ -23,7 +22,7 @@ export class UnitDeactivateGuard { private router: Router ) {} - private checkAndSolveMaxTime(newUnit: UnitWithContext | null): Observable { + private checkAndSolveMaxTime(newUnit: Unit | null): Observable { if (!this.tcs.currentMaxTimerTestletId) { // leaving unit is not in a timed block return of(true); } @@ -61,8 +60,8 @@ export class UnitDeactivateGuard { ); } - private checkAndSolveCompleteness(newUnit: UnitWithContext | null): Observable { - const direction = (!newUnit || this.tcs.currentUnitSequenceId < newUnit.unitDef.sequenceId) ? 'Next' : 'Prev'; + private checkAndSolveCompleteness(newUnit: Unit | null): Observable { + const direction = (!newUnit || this.tcs.currentUnitSequenceId < newUnit.sequenceId) ? 'Next' : 'Prev'; const reasons = this.checkCompleteness(direction); if (!reasons.length) { return of(true); @@ -71,8 +70,8 @@ export class UnitDeactivateGuard { } private checkCompleteness(direction: 'Next' | 'Prev'): VeronaNavigationDeniedReason[] { - const unit = this.tcs.getUnitWithContext(this.tcs.currentUnitSequenceId); - if (unit.unitDef.lockedByTime) { + const unit = this.tcs.getUnit(this.tcs.currentUnitSequenceId); + if (unit.lockedByTime) { return []; } const reasons: VeronaNavigationDeniedReason[] = []; @@ -80,16 +79,24 @@ export class UnitDeactivateGuard { Next: ['ON', 'ALWAYS'], Prev: ['ALWAYS'] }; + const presentationCompleteRequired = + unit.parent?.restrictions?.denyNavigationOnIncomplete?.presentation || + this.tcs.booklet?.config.force_presentation_complete || + 'OFF'; if ( - (checkOnValue[direction].indexOf(unit.unitDef.navigationLeaveRestrictions.presentationComplete) > -1) && + (checkOnValue[direction].includes(presentationCompleteRequired)) && this.tcs.hasUnitPresentationProgress(this.tcs.currentUnitSequenceId) && (this.tcs.getUnitPresentationProgress(this.tcs.currentUnitSequenceId) !== 'complete') ) { reasons.push('presentationIncomplete'); } + const responseCompleteRequired = + unit.parent?.restrictions?.denyNavigationOnIncomplete?.response || + this.tcs.booklet?.config.force_response_complete || + 'OFF'; const currentUnitResponseProgress = this.tcs.getUnitResponseProgress(this.tcs.currentUnitSequenceId); if ( - (checkOnValue[direction].indexOf(unit.unitDef.navigationLeaveRestrictions.responseComplete) > -1) && + (checkOnValue[direction].includes(responseCompleteRequired)) && currentUnitResponseProgress && (['complete', 'complete-and-valid'].indexOf(currentUnitResponseProgress) === -1) ) { @@ -145,16 +152,16 @@ export class UnitDeactivateGuard { return true; } - const currentUnit = this.tcs.getUnitWithContext(this.tcs.currentUnitSequenceId); + const currentUnit = this.tcs.getUnit(this.tcs.currentUnitSequenceId); if (currentUnit && this.tcs.getUnclearedTestlets(currentUnit).length) { return true; } - let newUnit: UnitWithContext | null = null; + let newUnit: Unit | null = null; const match = nextState.url.match(/t\/(\d+)\/u\/(\d+)$/); if (match) { const targetUnitSequenceId = Number(match[2]); - newUnit = this.tcs.getUnitWithContext(targetUnitSequenceId); + newUnit = this.tcs.getUnit(targetUnitSequenceId); } const forceNavigation = this.router.getCurrentNavigation()?.extras?.state?.force ?? false; @@ -165,7 +172,7 @@ export class UnitDeactivateGuard { return this.checkAndSolveCompleteness(newUnit) .pipe( - switchMap(cAsC => (!cAsC ? of(false) : this.checkAndSolveMaxTime(newUnit as UnitWithContext))) + switchMap(cAsC => (!cAsC ? of(false) : this.checkAndSolveMaxTime(newUnit))) ); } } diff --git a/frontend/src/app/test-controller/services/test-controller.service.ts b/frontend/src/app/test-controller/services/test-controller.service.ts index 4fcb30048..0392de9ae 100644 --- a/frontend/src/app/test-controller/services/test-controller.service.ts +++ b/frontend/src/app/test-controller/services/test-controller.service.ts @@ -6,22 +6,23 @@ import { } from 'rxjs'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; -import { MaxTimerData, Testlet, UnitWithContext } from '../classes/test-controller.classes'; +import { MaxTimerData } from '../classes/test-controller.classes'; import { + Booklet, KeyValuePairNumber, KeyValuePairString, LoadingProgress, MaxTimerDataType, StateReportEntry, - TestControllerState, - TestStateKey, + TestControllerState, Testlet, + TestStateKey, Unit, UnitDataParts, UnitNavigationTarget, UnitStateUpdate, WindowFocusState } from '../interfaces/test-controller.interfaces'; import { BackendService } from './backend.service'; -import { BookletConfig, TestMode } from '../../shared/shared.module'; +import { BookletDef, BookletConfig, TestMode } from '../../shared/shared.module'; import { VeronaNavigationDeniedReason } from '../interfaces/verona.interfaces'; import { MissingBookletError } from '../classes/missing-booklet-error.class'; import { MessageService } from '../../shared/services/message.service'; @@ -48,7 +49,8 @@ export class TestControllerService { testMode = new TestMode(); bookletConfig = new BookletConfig(); - rootTestlet: Testlet | null = null; + // rootTestlet: Testlet | null = null; + booklet: Booklet | null = null; maxTimeTimer$ = new Subject(); currentMaxTimerTestletId = ''; @@ -66,6 +68,9 @@ export class TestControllerService { resumeTargetUnitSequenceId = 0; private _navigationDenial = new Subject<{ sourceUnitSequenceId: number, reason: VeronaNavigationDeniedReason[] }>(); + + unitAliasMap: { [unitId: string] : number } = {}; // TODO X fill + get navigationDenial(): Observable<{ sourceUnitSequenceId: number, reason: VeronaNavigationDeniedReason[] }> { return this._navigationDenial; } @@ -98,9 +103,11 @@ export class TestControllerService { private unitResponseProgressStates: { [sequenceId: number]: string | undefined } = {}; private unitStateCurrentPages: { [sequenceId: number]: string | undefined } = {}; private unitContentLoadProgress$: { [sequenceId: number]: Observable } = {}; - private unitDefinitionTypes: { [sequenceId: number]: string } = {}; + private unitDefinitionTypes: { [sequenceId: number]: string } = {}; // TODO X WHAT THE FUCK private unitResponseTypes: { [sequenceId: number]: string } = {}; + units: { [sequenceId: number]: Unit } = {}; // TODO X implement + private unitDataPartsToSave$ = new Subject(); private unitDataPartsToSaveSubscription: Subscription | null = null; @@ -193,7 +200,7 @@ export class TestControllerService { this.players = {}; this.unitDefinitions = {}; this.unitStateDataParts = {}; - this.rootTestlet = null; + // this.rootTestlet = null; this.clearCodeTestlets = []; this.currentUnitSequenceId = 0; this.currentUnitDbKey = ''; @@ -400,19 +407,19 @@ export class TestControllerService { } } - getUnclearedTestlets(unit: UnitWithContext): Testlet[] { + getUnclearedTestlets(unit: Unit): Testlet[] { return unit.codeRequiringTestlets .filter(testlet => !this.clearCodeTestlets.includes(testlet.id)); } - getUnitIsLockedByCode(unit: UnitWithContext): boolean { - return this.getFirstSequenceIdOfLockedBlock(unit) !== unit.unitDef.sequenceId; + getUnitIsLockedByCode(unit: Unit): boolean { + return this.getFirstSequenceIdOfLockedBlock(unit) !== unit.sequenceId; } - getFirstSequenceIdOfLockedBlock(fromUnit: UnitWithContext): number { + getFirstSequenceIdOfLockedBlock(fromUnit: Unit): number { const unclearedTestlets = this.getUnclearedTestlets(fromUnit); if (!unclearedTestlets.length) { - return fromUnit.unitDef.sequenceId; + return fromUnit.sequenceId; } return unclearedTestlets .reduce((acc, item) => (acc.sequenceId < item.sequenceId ? acc : item)) @@ -420,15 +427,15 @@ export class TestControllerService { .filter(child => !!child.sequenceId)[0].sequenceId; } - getUnitIsLocked(unit: UnitWithContext): boolean { - return this.getUnitIsLockedByCode(unit) || unit.unitDef.lockedByTime; + getUnitIsLocked(unit: Unit): boolean { + return this.getUnitIsLockedByCode(unit) || unit.lockedByTime; } - getUnitWithContext(unitSequenceId: number): UnitWithContext { - if (!this.rootTestlet) { // when loading process was aborted + getUnit(unitSequenceId: number): Unit { + if (!this.booklet) { // when loading process was aborted throw new MissingBookletError(); } - const unit = this.rootTestlet.getUnitAt(unitSequenceId); + const unit = this.units[unitSequenceId]; if (!unit) { throw new AppError({ label: `Unit not found:${unitSequenceId}`, @@ -442,19 +449,22 @@ export class TestControllerService { getNextUnlockedUnitSequenceId(currentUnitSequenceId: number, reverse: boolean = false): number | null { const step = reverse ? -1 : 1; let nextUnitSequenceId = currentUnitSequenceId + step; - let nextUnit: UnitWithContext = this.getUnitWithContext(nextUnitSequenceId); + let nextUnit: Unit = this.getUnit(nextUnitSequenceId); while (nextUnit !== null && this.getUnitIsLocked(nextUnit)) { nextUnitSequenceId += step; - nextUnit = this.getUnitWithContext(nextUnitSequenceId); + nextUnit = this.getUnit(nextUnitSequenceId); } return nextUnit ? nextUnitSequenceId : null; } startMaxTimer(testlet: Testlet): void { + if (!testlet.restrictions?.timeMax) { + return; + } const timeLeftMinutes = (testlet.id in this.maxTimeTimers) ? - Math.min(this.maxTimeTimers[testlet.id], testlet.maxTimeLeft) : - testlet.maxTimeLeft; + Math.min(this.maxTimeTimers[testlet.id], testlet.restrictions.timeMax.minutes) : + testlet.restrictions.timeMax.minutes; if (this.maxTimeIntervalSubscription !== null) { this.maxTimeIntervalSubscription.unsubscribe(); } @@ -538,7 +548,7 @@ export class TestControllerService { setUnitNavigationRequest(navString: string, force = false): void { const targetIsCurrent = this.currentUnitSequenceId.toString(10) === navString; - if (!this.rootTestlet) { + if (!this.booklet) { this.router.navigate([`/t/${this.testId}/status`], { skipLocationChange: true, state: { force } }); } else { switch (navString) { @@ -605,4 +615,20 @@ export class TestControllerService { isUnitContentLoaded(sequenceId: number): boolean { return !!this.unitDefinitions[sequenceId]; } + + // eslint-disable-next-line class-methods-use-this + getAllUnitSequenceIds(testletId: string): number[] { // TODO X + return []; + } + + // eslint-disable-next-line class-methods-use-this + setTimeLeft(testletId: string, number: number): void { + // TODO X + } + + // eslint-disable-next-line class-methods-use-this + getMaxSequenceId(): number { + // TODO X + return NaN; + } } diff --git a/frontend/src/app/test-controller/services/test-loader.service.ts b/frontend/src/app/test-controller/services/test-loader.service.ts index 3e06a059e..9666e6509 100644 --- a/frontend/src/app/test-controller/services/test-loader.service.ts +++ b/frontend/src/app/test-controller/services/test-loader.service.ts @@ -6,10 +6,11 @@ import { import { concatMap, distinctUntilChanged, last, map, shareReplay, switchMap, tap } from 'rxjs/operators'; -import { CustomtextService, BookletConfig, TestMode } from '../../shared/shared.module'; +import { + CustomtextService, TestMode, UnitDef, TestletDef, BookletDef, ContextInBooklet +} from '../../shared/shared.module'; import { isLoadingFileLoaded, - isNavigationLeaveRestrictionValue, LoadedFile, LoadingProgress, StateReportEntry, @@ -20,17 +21,18 @@ import { TestStateKey, UnitData, UnitNavigationTarget, - UnitStateKey + UnitStateKey, Testlet, Booklet, Unit, isUnit } from '../interfaces/test-controller.interfaces'; -import { EnvironmentData, NavigationLeaveRestrictions, Testlet } from '../classes/test-controller.classes'; +import { EnvironmentData } from '../classes/test-controller.classes'; import { TestControllerService } from './test-controller.service'; import { BackendService } from './backend.service'; import { AppError } from '../../app.interfaces'; +import { BookletParserService } from '../../shared/services/bookletParser.service'; @Injectable({ providedIn: 'root' }) -export class TestLoaderService { +export class TestLoaderService extends BookletParserService { private loadStartTimeStamp = 0; private unitContentLoadSubscription: Subscription | null = null; private environment: EnvironmentData; // TODO (possible refactoring) outsource to a service or what @@ -43,6 +45,7 @@ export class TestLoaderService { private bs: BackendService, private cts: CustomtextService ) { + super(); this.environment = new EnvironmentData(); } @@ -60,7 +63,7 @@ export class TestLoaderService { this.tcs.workspaceId = testData.workspaceId; this.tcs.testMode = new TestMode(testData.mode); this.restoreRestrictions(testData.laststate); - this.tcs.rootTestlet = this.getBookletFromXml(testData.xml); + this.tcs.booklet = this.getBookletFromXml(testData.xml); this.tcs.timerWarningPoints = this.tcs.bookletConfig.unit_time_left_warnings @@ -70,7 +73,7 @@ export class TestLoaderService { await this.loadUnits(testData); this.prepareUnitContentLoadingQueueOrder(testData.laststate.CURRENT_UNIT_ID || '1'); - this.tcs.rootTestlet.lockUnitsIfTimeLeftNull(); + // this.tcs.rootTestlet.lockUnitsIfTimeLeftNull(); TODO what was this? // eslint-disable-next-line consistent-return return this.loadUnitContents(testData) @@ -97,13 +100,11 @@ export class TestLoaderService { } private resumeTest(lastState: { [k in TestStateKey]?: string }): void { - if (!this.tcs.rootTestlet) { + if (!this.tcs.booklet) { throw new AppError({ description: '', label: 'Booklet not loaded yet.', type: 'script' }); } const currentUnitId = lastState[TestStateKey.CURRENT_UNIT_ID]; - this.tcs.resumeTargetUnitSequenceId = currentUnitId ? - this.tcs.rootTestlet.getSequenceIdByUnitAlias(currentUnitId) : - 1; + this.tcs.resumeTargetUnitSequenceId = currentUnitId ? this.tcs.unitAliasMap[currentUnitId] : 1; if ( (lastState[TestStateKey.CONTROLLER] === TestControllerState.TERMINATED_PAUSED) || (lastState[TestStateKey.CONTROLLER] === TestControllerState.PAUSED) @@ -141,15 +142,15 @@ export class TestLoaderService { } private loadUnit(sequenceId: number, testData: TestData): Observable { - const unitDef = this.tcs.getUnitWithContext(sequenceId).unitDef; - const resources = testData.resources[unitDef.id.toUpperCase()]; + const unit = this.tcs.getUnit(sequenceId); + const resources = testData.resources[unit.id.toUpperCase()]; if (!resources) { - throw new Error(`No resources for unitId: \`${unitDef.id}\`.`); + throw new Error(`No resources for unitId: \`${unit.id}\`.`); } if (!(resources.usesPlayer && resources.usesPlayer.length)) { - throw new Error(`Unit has no player: \`${unitDef.id}\`)`); + throw new Error(`Unit has no player: \`${unit.id}\`)`); } - unitDef.playerFileName = resources.usesPlayer[0]; + unit.playerFileName = resources.usesPlayer[0]; const definitionFile = (resources.isDefinedBy && resources.isDefinedBy.length) ? resources.isDefinedBy[0] : null; @@ -157,35 +158,35 @@ export class TestLoaderService { // we don't need to call `[GET] /test/{testID}/unit` when this is the first test and no inline definition this.incrementTotalProgress({ progress: 100 }, `unit-${sequenceId}`); this.unitContentLoadingQueue.push({ sequenceId, definitionFile }); - return this.getPlayer(testData, sequenceId, unitDef.playerFileName); + return this.getPlayer(testData, sequenceId, unit.playerFileName); } - return this.bs.getUnitData(this.tcs.testId, unitDef.id, unitDef.alias) + return this.bs.getUnitData(this.tcs.testId, unit.id, unit.id) // TODO X alias ?!?! .pipe( - switchMap((unit: UnitData) => { - if (!unit) { - throw new Error(`Unit is empty ${this.tcs.testId}/${unitDef.id}.`); + switchMap((unitData: UnitData) => { + if (!unitData) { + throw new Error(`Unit is empty ${this.tcs.testId}/${unit.id}.`); } this.incrementTotalProgress({ progress: 100 }, `unit-${sequenceId}`); - this.tcs.setUnitPresentationProgress(sequenceId, unit.state[UnitStateKey.PRESENTATION_PROGRESS]); - this.tcs.setUnitResponseProgress(sequenceId, unit.state[UnitStateKey.RESPONSE_PROGRESS]); - this.tcs.setUnitStateCurrentPage(sequenceId, unit.state[UnitStateKey.CURRENT_PAGE_ID]); - this.tcs.setUnitStateDataParts(sequenceId, unit.dataParts); - this.tcs.setUnitResponseType(sequenceId, unit.unitResponseType); + this.tcs.setUnitPresentationProgress(sequenceId, unitData.state[UnitStateKey.PRESENTATION_PROGRESS]); + this.tcs.setUnitResponseProgress(sequenceId, unitData.state[UnitStateKey.RESPONSE_PROGRESS]); + this.tcs.setUnitStateCurrentPage(sequenceId, unitData.state[UnitStateKey.CURRENT_PAGE_ID]); + this.tcs.setUnitStateDataParts(sequenceId, unitData.dataParts); + this.tcs.setUnitResponseType(sequenceId, unitData.unitResponseType); if (definitionFile) { this.unitContentLoadingQueue.push({ sequenceId, definitionFile }); } else { // inline unit definition - this.tcs.setUnitPlayerFilename(sequenceId, unitDef.playerFileName); - this.tcs.setUnitDefinition(sequenceId, unit.definition); + // this.tcs.setUnitPlayerFilename(sequenceId, unit.playerFileName); // TODO X DAFUQ? + this.tcs.setUnitDefinition(sequenceId, unitData.definition); this.tcs.setUnitLoadProgress$(sequenceId, of({ progress: 100 })); this.incrementTotalProgress({ progress: 100 }, `content-${sequenceId}`); } - return this.getPlayer(testData, sequenceId, unitDef.playerFileName); + return this.getPlayer(testData, sequenceId, unit.playerFileName); }) ); } @@ -214,13 +215,13 @@ export class TestLoaderService { ); } - private prepareUnitContentLoadingQueueOrder(currentUnitId: string = '1'): void { - if (!this.tcs.rootTestlet) { + private prepareUnitContentLoadingQueueOrder(currentUnitId: string = '1'): void { // TODO X machte der default Sinn? + if (!this.tcs.booklet) { throw new AppError({ description: '', label: 'Testheft noch nicht verfügbar', type: 'script' }); } - const currentUnitSequenceId = this.tcs.rootTestlet.getSequenceIdByUnitAlias(currentUnitId); + const currentUnitSequenceId = this.tcs.unitAliasMap[currentUnitId]; const queue = this.unitContentLoadingQueue; let firstToLoadQueuePosition; for (firstToLoadQueuePosition = 0; firstToLoadQueuePosition < queue.length; firstToLoadQueuePosition++) { @@ -313,171 +314,67 @@ export class TestLoaderService { this.tcs.totalLoadingProgress = (sumOfProgresses / maxProgresses) * 100; } - private getBookletFromXml(xmlString: string): Testlet { - const oParser = new DOMParser(); - const xmlStringWithOutBom = xmlString.replace(/^\uFEFF/gm, ''); - const oDOM = oParser.parseFromString(xmlStringWithOutBom, 'text/xml'); + private getBookletFromXml(xmlString: string): Booklet { + const booklet = this.parseBookletXml(xmlString); - if (oDOM.documentElement.nodeName !== 'Booklet') { - throw Error('Root element fo Booklet should be '); - } - const metadataElements = oDOM.documentElement.getElementsByTagName('Metadata'); - if (metadataElements.length === 0) { - throw Error('-Element missing'); - } - const metadataElement = metadataElements[0]; - const IdElement = metadataElement.getElementsByTagName('Id')[0]; - const LabelElement = metadataElement.getElementsByTagName('Label')[0]; - const 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: { [key: string] : string } = {}; - for (let childIndex = 0; childIndex < customTexts.length; childIndex++) { - if (customTexts[childIndex].nodeName === 'CustomText') { - const customTextKey = customTexts[childIndex].getAttribute('key'); - if (customTextKey) { - customTextsForBooklet[customTextKey] = customTexts[childIndex].textContent || ''; - } + const registerChildren = (testlet: Testlet): void => { + testlet.children + .forEach(child => { + // eslint-disable-next-line no-plusplus + if (isUnit(child)) { + this.tcs.unitAliasMap[child.id] = child.sequenceId; + this.tcs.units[child.sequenceId] = child; + } else { + registerChildren(child); } - } - this.cts.addCustomTexts(customTextsForBooklet); - } + }); + }; - const bookletConfigElements = oDOM.documentElement.getElementsByTagName('BookletConfig'); + registerChildren(booklet.units); - this.tcs.bookletConfig = new BookletConfig(); - if (bookletConfigElements.length > 0) { - this.tcs.bookletConfig.setFromXml(bookletConfigElements[0]); - } + this.tcs.bookletConfig = booklet.config; + this.cts.addCustomTexts(booklet.customTexts); + return booklet; + } - // 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; + // eslint-disable-next-line class-methods-use-this + toBooklet(bookletDef: BookletDef): Booklet { + return Object.assign(bookletDef, {}); } - 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 = 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 (presentationComplete && isNavigationLeaveRestrictionValue(presentationComplete)) { - forcePresentationComplete = presentationComplete; - } - const responseComplete = restrictionElements[childIndex].getAttribute('response'); - if (responseComplete && isNavigationLeaveRestrictionValue(responseComplete)) { - forceResponseComplete = responseComplete; - } - } - } - } + // eslint-disable-next-line class-methods-use-this + toTestlet(testletDef: TestletDef, elem: Element, context: ContextInBooklet): Testlet { + return Object.assign(testletDef, { + sequenceId: context.globalIndex + }); + } - if (codeToEnter.length > 0) { - targetTestlet.codeToEnter = codeToEnter; - targetTestlet.codePrompt = codePrompt; + // eslint-disable-next-line class-methods-use-this + toUnit(unitDef: UnitDef, elem: Element, context: ContextInBooklet): Unit { + const codeRequiringTestlets: Testlet[] = []; + let maxTimerRequiringTestlet = null; + let parent = context.parent; + let testletLabel = ''; + while (context.parent) { + parent = context.parent; + if (parent.restrictions?.codeToEnter?.code) { + codeRequiringTestlets.push(parent); } - 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'); - if (!unitId) { - throw new AppError({ - description: '', label: `Unit-Id Fehlt in unit nr ${childIndex} von ${targetTestlet.id}`, type: 'xml' - }); - } - 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 = childElements[childIndex].getAttribute('id'); - if (!testletId) { - throw new AppError({ - description: '', label: `Testlet-Id fehlt in unit nr ${childIndex} von ${targetTestlet.id}`, type: 'xml' - }); - } - const testletLabel: string = childElements[childIndex].getAttribute('label')?.trim() ?? ''; - - this.addTestletContentFromBookletXml( - targetTestlet.addTestlet(testletId, testletLabel), - childElements[childIndex], - newNavigationLeaveRestrictions - ); - } + } + if (parent) { + if (parent.restrictions?.timeMax?.minutes) { + maxTimerRequiringTestlet = parent; } + testletLabel = parent.label; } + return Object.assign(unitDef, { + sequenceId: context.globalIndex, + codeRequiringTestlets, + maxTimerRequiringTestlet, + testletLabel, + parent, + lockedByTime: false, + playerFileName: '' + }); } }