mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 15:04:04 +00:00
hint after a passed Antritt; split Cypress setup into global, suite and local layers; if configured a pass closes the Antritt chain: the next exam cell shows "Positiv abgeschlossen" with a tooltip, and an add attempt shows pruefungNachBestandenerNote; 8 new specs with 55 tests: kettenregeln, terminregeln, scope, rollenmatrix, antrittrollen, nebenlaeufigkeit, UI ablehnung and navigation; WIP documenting testsuite caveats in readme files; WIP streamlining layers and removing redundant code/fallbacks;
This commit is contained in:
+2
-21
@@ -2,9 +2,6 @@ const { defineConfig } = require("cypress");
|
||||
|
||||
require("dotenv").config({ path: "tests/cypress/.env" });
|
||||
|
||||
// The suite being run: its cy.task handlers and its Cypress.env() values.
|
||||
const suite = require("./tests/cypress/suites/noten");
|
||||
|
||||
module.exports = defineConfig({
|
||||
// Cypress 15 deprecates Cypress.env() inside cy.request options, which the API helpers use.
|
||||
allowCypressEnv: true,
|
||||
@@ -28,29 +25,13 @@ module.exports = defineConfig({
|
||||
const missing = ["BASE_URL", "USER_NAME", "USER_PASSWORD"].filter((key) => !process.env[key]);
|
||||
if (missing.length) throw new Error(`Missing in tests/cypress/.env: ${missing.join(", ")}`);
|
||||
|
||||
suite.registerTasks(on);
|
||||
|
||||
// the suite can switch the instance to a configuration profile for the run
|
||||
on("before:run", () => suite.beforeRun(config));
|
||||
|
||||
// the profile goes back first; the pool and the SSH forward close even if that fails
|
||||
on("after:run", async () => {
|
||||
try {
|
||||
await suite.afterRun(config);
|
||||
} finally {
|
||||
await require("./tests/cypress/tasks/db").closeDb();
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
// each suite registers its own tasks and Cypress.env() values
|
||||
return require("./tests/cypress/suites/noten").setupNodeEvents(on, config);
|
||||
},
|
||||
},
|
||||
|
||||
env: {
|
||||
// HTTP Basic + LDAP user the API calls authenticate as.
|
||||
adminusername: process.env.USER_NAME,
|
||||
adminpassword: process.env.USER_PASSWORD,
|
||||
|
||||
...suite.env,
|
||||
},
|
||||
});
|
||||
|
||||
+8
-26
@@ -1,34 +1,16 @@
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint tests/cypress cypress.config.js eslint.config.js",
|
||||
"lint:fix": "eslint tests/cypress cypress.config.js eslint.config.js --fix",
|
||||
"format": "prettier --write \"tests/cypress/**/*.{js,md}\"",
|
||||
"format:check": "prettier --check \"tests/cypress/**/*.{js,md}\"",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
"cy:noten": "cypress run --spec \"tests/cypress/e2e/specs/**/*.cy.js\"",
|
||||
"cy:noten:api": "cypress run --spec \"tests/cypress/e2e/specs/{unit,api}/**/*.cy.js\"",
|
||||
"cy:noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"",
|
||||
"cy:noten:ui:watch": "cypress run --browser chrome --headed --no-exit --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\"",
|
||||
"cy:noten:punkte-on": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=on --spec \"tests/cypress/e2e/specs/**/*.cy.js\"",
|
||||
"cy:noten:punkte-off": "cypress run --browser chrome --env NOTEN_PUNKTE_MODUS=off --spec \"tests/cypress/e2e/specs/**/*.cy.js\"",
|
||||
"cy:noten:p0": "cypress run --spec \"tests/cypress/e2e/specs/unit/notenRules.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js,tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js\"",
|
||||
"cy:noten:profile": "node tests/cypress/tools/notenProfile.js lauf",
|
||||
"noten:profil": "node tests/cypress/tools/notenProfile.js",
|
||||
"noten:check": "node tests/cypress/tools/dbCheck.js noten"
|
||||
"noten:check": "node tests/cypress/tools/dbCheck.js noten",
|
||||
"noten:api": "cypress run --spec \"tests/cypress/e2e/specs/{unit,api}/**/*.cy.js\"",
|
||||
"noten:ui": "cypress run --browser chrome --spec \"tests/cypress/e2e/specs/ui/**/*.cy.js\""
|
||||
},
|
||||
"//": [
|
||||
"pg: the suite resets and seeds the test database directly (tasks/db.js, tools/dbCheck.js)",
|
||||
"dotenv: cypress.config.js and the suite read URL, accounts and DB access from the .env files"
|
||||
],
|
||||
"dependencies": {
|
||||
"cypress": "^15.21.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"pg": "^8.13.1",
|
||||
"ssh2": "^1.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-cypress": "^7.0.2",
|
||||
"globals": "^17.12.0",
|
||||
"prettier": "^3.9.7"
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,18 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Reason instead of a button, for example, after passing a test */
|
||||
.pruefung-cell .pruefung-hint {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.pruefung-cell .pruefung-datum {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -493,8 +493,9 @@ export const Benotungstool = {
|
||||
let count = 0
|
||||
this.studenten?.forEach(s => {
|
||||
// Add one more column while this row can get one more Termin. The kommissionelle
|
||||
// Prüfung is the last Termin, therefore it uses such a column too.
|
||||
const frei = this.canAddPruefung(s) ? 1 : 0
|
||||
// Prüfung is the last Termin, therefore it uses such a column too. A row closed by
|
||||
// a pass also gets the column: the cell names the reason.
|
||||
const frei = (this.canAddPruefung(s) || this.showBestandenHint(s)) ? 1 : 0
|
||||
const needed = (s.pruefungen?.length ?? 0) + frei
|
||||
if(needed > count) count = needed
|
||||
})
|
||||
@@ -524,6 +525,11 @@ export const Benotungstool = {
|
||||
return NotenRules.canAddPruefung(student, this.config)
|
||||
},
|
||||
|
||||
/** A pass closed the chain: the next exam cell names the reason. A credited row stays empty. */
|
||||
showBestandenHint(student) {
|
||||
return !this.canAddPruefung(student) && NotenRules.isBestanden(student) && !student.verlauf?.angerechnet
|
||||
},
|
||||
|
||||
checkFreigabe(freigabedatum, benotungsdatum) {
|
||||
return NotenRules.checkFreigabe(freigabedatum, benotungsdatum)
|
||||
},
|
||||
@@ -1650,7 +1656,9 @@ export const Benotungstool = {
|
||||
// An empty cell gets an add button only if one more Termin is possible and if the column
|
||||
// is after all exams that exist. The kommissionelle Prüfung needs no own column: the
|
||||
// server decides the role of the new exam, the cell then shows the K badge.
|
||||
if(!this.canAddPruefung(data)) return ''
|
||||
// A pass closes the chain. That cell shows the reason instead of the button.
|
||||
const bestanden = this.showBestandenHint(data)
|
||||
if(!this.canAddPruefung(data) && !bestanden) return ''
|
||||
|
||||
if(antrittModus) {
|
||||
// only the next free Termin column shows an add button
|
||||
@@ -1661,6 +1669,14 @@ export const Benotungstool = {
|
||||
if((data.pruefungen ?? []).some(p => gleicherTag ? p.datum > field : p.datum >= field)) return ''
|
||||
}
|
||||
|
||||
if(bestanden) {
|
||||
rowDiv.classList.add('ohne-aktion')
|
||||
const hint = addSlot('pruefung-hint', this.$capitalize(this.$p.t('benotungstool/c4bestandenHint')))
|
||||
hint.title = this.$capitalize(this.$p.t('benotungstool/c4bestandenTooltip'))
|
||||
hint.dataset.cy = 'pruefung-bestanden'
|
||||
return rowDiv
|
||||
}
|
||||
|
||||
addButton(
|
||||
this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')),
|
||||
null,
|
||||
@@ -2422,7 +2438,7 @@ export const Benotungstool = {
|
||||
|
||||
/**
|
||||
* Meldet, warum diese Zeile keinen Antritt mehr bekommt. Die gesperrte kommissionelle
|
||||
* Prüfung nennt einen anderen Grund als die erreichte Grenze.
|
||||
* Prüfung und eine bestandene Note nennen einen anderen Grund als die erreichte Grenze.
|
||||
*/
|
||||
warnKeinAntritt(student, nachsatz) {
|
||||
if(student.verlauf?.kommPruefGesperrt) {
|
||||
@@ -2430,6 +2446,11 @@ export const Benotungstool = {
|
||||
return
|
||||
}
|
||||
|
||||
if(NotenRules.isBestanden(student)) {
|
||||
this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/pruefungNachBestandenerNote', [student.uid])))
|
||||
return
|
||||
}
|
||||
|
||||
this.$fhcAlert.alertWarning(
|
||||
this.$capitalize(this.$p.t('benotungstool/c4antritteAufgebraucht',
|
||||
[student.uid, student.hoechsterAntritt, nachsatz]))
|
||||
|
||||
@@ -58,6 +58,9 @@ export const canAddPruefung = (student, config) => {
|
||||
return !hatKommPruef && antrittCountStudent(student, config) < maxAntrittCount(config);
|
||||
};
|
||||
|
||||
/** Tells you if a pass closed the chain. Only the server knows it; without a `verlauf` the answer is false. */
|
||||
export const isBestanden = (student) => Boolean(student?.verlauf?.bestanden);
|
||||
|
||||
/**
|
||||
* Tells you if the new exam also creates the course grade. The value comes from the `verlauf` and
|
||||
* not from `lv_note`, because `lv_note` contains RELEASED grades only. An entered grade that is
|
||||
|
||||
@@ -64978,6 +64978,46 @@ I have been informed that I am under no obligation to consent to the transmissio
|
||||
)
|
||||
)
|
||||
),
|
||||
array(
|
||||
'app' => 'core',
|
||||
'category' => 'benotungstool',
|
||||
'phrase' => 'c4bestandenHint',
|
||||
'insertvon' => 'system',
|
||||
'phrases' => array(
|
||||
array(
|
||||
'sprache' => 'German',
|
||||
'text' => 'Positiv abgeschlossen',
|
||||
'description' => '',
|
||||
'insertvon' => 'system'
|
||||
),
|
||||
array(
|
||||
'sprache' => 'English',
|
||||
'text' => 'Passed',
|
||||
'description' => '',
|
||||
'insertvon' => 'system'
|
||||
)
|
||||
)
|
||||
),
|
||||
array(
|
||||
'app' => 'core',
|
||||
'category' => 'benotungstool',
|
||||
'phrase' => 'c4bestandenTooltip',
|
||||
'insertvon' => 'system',
|
||||
'phrases' => array(
|
||||
array(
|
||||
'sprache' => 'German',
|
||||
'text' => 'Die Note des letzten Antritts ist positiv. Eine weitere Prüfung ist nicht möglich.',
|
||||
'description' => '',
|
||||
'insertvon' => 'system'
|
||||
),
|
||||
array(
|
||||
'sprache' => 'English',
|
||||
'text' => 'The grade of the last attempt is a pass. No further exam is possible.',
|
||||
'description' => '',
|
||||
'insertvon' => 'system'
|
||||
)
|
||||
)
|
||||
),
|
||||
// CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------
|
||||
);
|
||||
|
||||
|
||||
@@ -1,74 +1,9 @@
|
||||
# Copy to tests/cypress/.env (gitignored) and fill in.
|
||||
# THIS FILE IS COMMITTED - never put a real password in it.
|
||||
# Only the keys every suite shares. A suite keeps its own keys in tests/cypress/suites/.env.
|
||||
|
||||
# ---- required -------------------------------------------------------------------------------
|
||||
|
||||
# Your own instance, e.g. https://c3p0.dev.technikum-wien.at/<kuerzel>/fhcompletecis4
|
||||
# No default anywhere: empty fails the run rather than pointing it at a colleague's installation.
|
||||
# The instance under test. No default anywhere: empty fails the run rather than pointing it at a colleague's installation.
|
||||
BASE_URL=
|
||||
|
||||
# HTTP Basic + LDAP user the API calls authenticate as. Use the LECTURER under test
|
||||
# (e.g. demolektor1) - getBenotungstoolContext resolves that user's own Lehrveranstaltungen,
|
||||
# and an admin account usually teaches nothing and would resolve none.
|
||||
# HTTP Basic + LDAP user for Cypress.env("adminusername") / ("adminpassword").
|
||||
USER_NAME=
|
||||
USER_PASSWORD=
|
||||
|
||||
# ---- required: fixture reset ------------------------------------------------------------------
|
||||
# The suite resets its fixture over a direct database connection; there is no HTTP fallback, so
|
||||
# the specs fail without it. TEST_ENV_PREFIX selects the <PREFIX>_DB_* / <PREFIX>_SSH_* names.
|
||||
TEST_ENV_PREFIX=NOTEN
|
||||
|
||||
NOTEN_DB_HOST=db.dev.technikum-wien.at
|
||||
NOTEN_DB_PORT=5432
|
||||
NOTEN_DB_NAME=
|
||||
NOTEN_DB_USER=
|
||||
NOTEN_DB_PASSWORD=
|
||||
NOTEN_DB_ALLOW_WRITES=true
|
||||
|
||||
# ---- required from a workstation: SSH tunnel --------------------------------------------------
|
||||
# pg_hba.conf admits only the application server. NOTEN_DB_HOST above stays the REAL database -
|
||||
# the tunnel rewrites the connection to 127.0.0.1 itself.
|
||||
#
|
||||
# Two things that are easy to get wrong:
|
||||
# - the SSH host is usually NOT the web hostname
|
||||
# - a key whose filename is not an OpenSSH default is NEVER tried automatically; name it here,
|
||||
# or leave NOTEN_SSH_KEY empty to use your SSH agent
|
||||
NOTEN_SSH_TUNNEL=true
|
||||
NOTEN_SSH_HOST=
|
||||
NOTEN_SSH_USER=
|
||||
NOTEN_SSH_KEY=
|
||||
|
||||
# ---- optional ---------------------------------------------------------------------------------
|
||||
|
||||
# Semester and LV are discovered at runtime. Pin them only when discovery picks the wrong one.
|
||||
NOTEN_SEM=
|
||||
NOTEN_LV_ID=
|
||||
|
||||
# Access-control specs: this API has no impersonation, so scoping needs a second real login.
|
||||
# NOTEN_FOREIGN_LV_ID must be an LV this teacher does NOT teach. Unset -> those specs skip.
|
||||
NOTEN_TEACHER_USER=
|
||||
NOTEN_TEACHER_PASSWORD=
|
||||
NOTEN_FOREIGN_LV_ID=
|
||||
|
||||
# Configuration profiles (tests/cypress/profiles/noten.js) change application/config/noten.php and the
|
||||
# define() flags in config/global.config.inc.php on the instance, over SSH, and restore them after the
|
||||
# run. NOTEN_REMOTE_ROOT is the absolute path of the instance on its host.
|
||||
# npm run cy:noten:profile every profile, then a report
|
||||
# npm run noten:profil -- status the active profile
|
||||
NOTEN_REMOTE_ROOT=
|
||||
# Only if the instance runs on another host than the SSH tunnel above. With NOTEN_PROFIL_SSH_HOST set,
|
||||
# every profile SSH value comes from NOTEN_PROFIL_SSH_*; an empty key uses the SSH agent (on Windows
|
||||
# the OpenSSH agent service). Without it the profiles use NOTEN_SSH_*.
|
||||
NOTEN_PROFIL_SSH_HOST=
|
||||
NOTEN_PROFIL_SSH_USER=
|
||||
NOTEN_PROFIL_SSH_KEY=
|
||||
# Only for a test instance with a self-signed certificate: the Node side (profile check, lock test)
|
||||
# rejects it otherwise. Never set this for anything else.
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
# a profile for every plain run; empty = the instance as deployed
|
||||
NOTEN_PROFIL=
|
||||
|
||||
# LDAP password of USER_NAME for the Freigabe specs. Empty -> USER_PASSWORD.
|
||||
# A successful Freigabe sends the Notenfreigabe mail. Run the suite only on an instance whose mail goes to a
|
||||
# debug mailbox (MAIL_DEBUG), as on the dev machines.
|
||||
NOTEN_FREIGABE_PASSWORD=
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Access control (P2) - das Scoping von assertLvAccess.
|
||||
* Access control - the scope of `assertLvAccess`.
|
||||
*
|
||||
* Diese API kennt keine Impersonation, der Lektoren-Scope braucht daher echte Zugangsdaten:
|
||||
* This API does not support impersonation. therefore the lecturer scope requires valid credentials:
|
||||
* NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD / NOTEN_FOREIGN_LV_ID.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import { notenApi, notenAuth } from "../../../../support/api/notenApi";
|
||||
import { expectAuthError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
attemptDate,
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { skipWenn } from "../../../../support/helpers/notenConfig";
|
||||
import { skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
assistenzAuth,
|
||||
assistenzKontext,
|
||||
assistenzLvMitLehreinheiten,
|
||||
assistenzContext,
|
||||
assistenzLvWithLehreinheiten,
|
||||
requireAssistenz,
|
||||
} from "../../../../support/helpers/notenAssistenz";
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
|
||||
describe("Scope der Lehrperson", () => {
|
||||
beforeEach(function () {
|
||||
skipWenn(
|
||||
skipIf(
|
||||
this,
|
||||
!teacherConfigured(),
|
||||
"Übersprungen: braucht NOTEN_TEACHER_USER / NOTEN_TEACHER_PASSWORD und NOTEN_FOREIGN_LV_ID " +
|
||||
@@ -125,25 +125,22 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// getLvForStudiengang ist NICHT abgedeckt: als Admin greift isBerechtigt('admin') und jeder
|
||||
// Aufruf gelingt. Braucht denselben Nicht-Admin-Login wie die Lektorentests oben.
|
||||
|
||||
// getLvForStudiengang is NOT covered: as an admin, isBerechtigt(‘admin’) applies and every
|
||||
// call succeeds. Requires the same non-admin login as the reviewer tests above.
|
||||
describe("getBenotungstoolContext als Assistenz", () => {
|
||||
beforeEach(function () {
|
||||
requireAssistenz(this);
|
||||
cy.clearAllCookies();
|
||||
});
|
||||
|
||||
// C1: Der Assistenz-Zweig lud früher als einziger das StudiengangModel. Der Test schützt ihn.
|
||||
|
||||
it("liefert die berechtigten Studiengänge", () => {
|
||||
assistenzKontext(ctx.semKurzbz).then((kontext) => {
|
||||
expect(kontext, "ein Semester mit Studiengängen der Assistenz").to.not.be.null;
|
||||
expect(kontext.data.isAssistenz, "isAssistenz").to.be.true;
|
||||
expect(kontext.data.studiengaenge, "studiengaenge").to.be.an("array").and.not.be.empty;
|
||||
assistenzContext(ctx.semKurzbz).then((result) => {
|
||||
expect(result, "ein Semester mit Studiengängen der Assistenz").to.not.be.null;
|
||||
expect(result.data.isAssistenz, "isAssistenz").to.be.true;
|
||||
expect(result.data.studiengaenge, "studiengaenge").to.be.an("array").and.not.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
// W9: Die alte Route las das Semester als lv_id.
|
||||
|
||||
it("ignoriert eine lv_id, die nicht nur aus Ziffern besteht", () => {
|
||||
getAs(assistenzAuth(), "getBenotungstoolContext", {
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
@@ -155,12 +152,12 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// C5: Eine leere oder nicht numerische lv_id weitete die Zugriffsprüfung auf alle LVs aus.
|
||||
// An empty or non-numeric lv_id would extend the access check to all LVs.
|
||||
describe("Parameter der Zugriffsprüfung", () => {
|
||||
const s0 = () => ctx.students[0];
|
||||
|
||||
// saveStudentenNoten fehlt: Der Endpunkt prüft zuerst das Passwort und verschickt eine Mail.
|
||||
const endpunkte = {
|
||||
const endpoints = {
|
||||
getStudentenNoten: (lv, sem) => notenApi.getStudentenNoten(lv, sem),
|
||||
getLehreinheitenFuerLv: (lv, sem) => notenApi.getLehreinheitenFuerLv(lv, sem),
|
||||
getNoteByPunkte: (lv, sem) => notenApi.getNoteByPunkte(50, lv, sem),
|
||||
@@ -176,21 +173,21 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
lehreinheit_id: s0().lehreinheit_id,
|
||||
sem_kurzbz: sem,
|
||||
}),
|
||||
// createPruefungen prüft uids vor der Zugriffsprüfung
|
||||
// createPruefungen checks UIDs before performing the access check
|
||||
createPruefungen: (lv, sem) => notenApi.createPruefungen([{ uid: s0().uid }], attemptDate(ctx, 1), lv, sem),
|
||||
savePruefungenBulk: (lv, sem) => notenApi.savePruefungenBulk(lv, sem, []),
|
||||
};
|
||||
|
||||
Object.entries(endpunkte).forEach(([name, aufruf]) => {
|
||||
Object.entries(endpoints).forEach(([name, call]) => {
|
||||
[null, 0, "", "abc", "1abc", -1].forEach((lv) => {
|
||||
it(`${name} lehnt lv_id=${JSON.stringify(lv)} ab`, () => {
|
||||
aufruf(lv, ctx.semKurzbz).then((response) => expectNotenError(response, "wrongParameters"));
|
||||
call(lv, ctx.semKurzbz).then((response) => expectNotenError(response, "wrongParameters"));
|
||||
});
|
||||
});
|
||||
|
||||
[null, "", " "].forEach((sem) => {
|
||||
it(`${name} lehnt sem_kurzbz=${JSON.stringify(sem)} ab`, () => {
|
||||
aufruf(ctx.lvId, sem).then((response) => expectNotenError(response, "wrongParameters"));
|
||||
call(ctx.lvId, sem).then((response) => expectNotenError(response, "wrongParameters"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -201,25 +198,24 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
|
||||
// Der alte Code überschrieb eine LV-Note einer anderen LV. Diese Zeile setzt kein Reset zurück.
|
||||
const suche = (i) =>
|
||||
const search = (i) =>
|
||||
i >= ctx.students.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: cy
|
||||
.task("noten:db:andereLvNoten", {
|
||||
.task("noten:db:otherLvNoten", {
|
||||
lvId: ctx.lvId,
|
||||
semKurzbz: ctx.semKurzbz,
|
||||
studentUid: ctx.students[i].uid,
|
||||
})
|
||||
.then((anzahl) => (anzahl === 0 ? ctx.students[i] : suche(i + 1)));
|
||||
.then((count) => (count === 0 ? ctx.students[i] : search(i + 1)));
|
||||
|
||||
suche(0).then((found) => {
|
||||
search(0).then((found) => {
|
||||
student = found;
|
||||
});
|
||||
});
|
||||
|
||||
it("ändert bei lv_id=null keine LV-Note", function () {
|
||||
skipWenn(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs.");
|
||||
skipIf(this, !student, "Übersprungen: jeder Studierende hat LV-Noten in anderen LVs.");
|
||||
|
||||
const g2 = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
|
||||
|
||||
@@ -237,14 +233,14 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W10: Der Endpunkt liefert alle Lehreinheiten der LV, für jede Rolle mit Zugriff auf die LV.
|
||||
// The endpoint returns all course units for the course, for each role with access to the course.
|
||||
describe("getLehreinheitenFuerLv", () => {
|
||||
const lehreinheitenDerLv = (lvId) =>
|
||||
const lehreinheitenOfLv = (lvId) =>
|
||||
cy
|
||||
.task("noten:db:lehreinheitenDerLv", { lvId, semKurzbz: ctx.semKurzbz })
|
||||
.task("noten:db:lehreinheitenOfLv", { lvId, semKurzbz: ctx.semKurzbz })
|
||||
.then((ids) => ids.map(String).sort());
|
||||
|
||||
// die Abfrage liefert eine Zeile je Gruppe, eine Lehreinheit kann also mehrfach vorkommen
|
||||
// The query returns one row per group, so a course may appear multiple times
|
||||
const idsOf = (rows) => [...new Set(rows.map((r) => String(r.lehreinheit_id)))].sort();
|
||||
|
||||
before(() => requireDbReset());
|
||||
@@ -268,7 +264,7 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
"studentcount",
|
||||
);
|
||||
|
||||
lehreinheitenDerLv(ctx.lvId).then((ids) => {
|
||||
lehreinheitenOfLv(ctx.lvId).then((ids) => {
|
||||
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(ids);
|
||||
});
|
||||
});
|
||||
@@ -278,13 +274,13 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
requireAssistenz(this);
|
||||
cy.clearAllCookies();
|
||||
|
||||
assistenzLvMitLehreinheiten(ctx.semKurzbz).then((ziel) => {
|
||||
expect(ziel, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
|
||||
assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => {
|
||||
expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
|
||||
|
||||
getAs(assistenzAuth(), "getLehreinheitenFuerLv", { lv_id: ziel.lvId, sem_kurzbz: ziel.sem }).then(
|
||||
getAs(assistenzAuth(), "getLehreinheitenFuerLv", { lv_id: target.lvId, sem_kurzbz: target.sem }).then(
|
||||
(response) => {
|
||||
const rows = expectNotenSuccess(response, "getLehreinheitenFuerLv als Assistenz");
|
||||
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(ziel.lehreinheiten);
|
||||
expect(idsOf(rows), "lehreinheit_id").to.deep.eq(target.lehreinheiten);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -294,7 +290,7 @@ describe("Noten API - Zugriffsschutz", () => {
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/Lehre/getLeForLv",
|
||||
auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") },
|
||||
auth: notenAuth(),
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
expect(response.status, "Status von Lehre/getLeForLv").to.not.eq(200);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* From which attempt the exam is kommissionell.
|
||||
*
|
||||
* The number comes from CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT, not from this test. Checks that
|
||||
* the built chain follows it: role -> legacy type -> the `kommissionell` flag.
|
||||
*/
|
||||
|
||||
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import { attemptDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
attemptsOfStudent,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - ab welchem Antritt kommissionell", () => {
|
||||
let ctx;
|
||||
let fromAntritt; // null means no Antritt is kommissionell
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
fromAntritt = context.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
|
||||
cy.log(`KOMMISSIONELL_AB_ANTRITT = ${fromAntritt} maxAntritte = ${context.maxAntritte}`);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
it("liefert eine Antrittsnummer, die zur Kette passt", function () {
|
||||
skipIf(this, fromAntritt === null, "Übersprungen: kein Antritt ist kommissionell.");
|
||||
|
||||
expect(fromAntritt, "die Nummer ist eine Zahl ab 1").to.be.a("number").and.to.be.at.least(1);
|
||||
expect(fromAntritt, "sie liegt innerhalb der Kette").to.be.at.most(ctx.maxAntritte);
|
||||
});
|
||||
|
||||
it("baut die Kette nach der konfigurierten Nummer", function () {
|
||||
skipIf(
|
||||
this,
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false,
|
||||
"Übersprungen: das Werkzeug darf den letzten Antritt nicht anlegen.",
|
||||
);
|
||||
|
||||
const student = ctx.students[0];
|
||||
expect(student, "ein Student der LV").to.exist;
|
||||
|
||||
// The base step produces step 1. A negative note keeps the chain open.
|
||||
givenBaseline(ctx, student, { note: ctx.notes.negativ });
|
||||
|
||||
for (let nr = 2; nr <= ctx.maxAntritte; nr += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, nr - 1) }).then((response) =>
|
||||
expectNotenSuccess(response, `Antritt ${nr}`),
|
||||
);
|
||||
}
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const attempts = attemptsOfStudent(data, student.uid);
|
||||
expect(attempts.length, "die Kette ist vollständig").to.eq(ctx.maxAntritte);
|
||||
|
||||
attempts.forEach((termin, index) => {
|
||||
const nr = index + 1;
|
||||
const expected = fromAntritt !== null && nr >= fromAntritt;
|
||||
|
||||
expect(termin.kommissionell, `Antritt ${nr} kommissionell?`).to.eq(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Bulk- und Importpfade (P2). Dieselben Guards wie beim einzelnen Speichern, aber je Zeile
|
||||
* gemeldet: HTTP 200 mit der Fehlermeldung in data[uid].
|
||||
* Bulk and import paths. Same guards as for individual saves, but reported per row
|
||||
* : HTTP 200 with the error message in data[uid].
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import { expectBulkRowAccepted, expectBulkRowError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
requireKonfiguration,
|
||||
requireNotenModus,
|
||||
requirePunkteModus,
|
||||
requireConfig,
|
||||
requireNotenMode,
|
||||
requirePunkteMode,
|
||||
requireWiederholung,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
@@ -31,8 +31,8 @@ describe("Noten API - Sammelpfade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Führt den Studierenden bis an die Grenze. Die Baseline liefert Antritt 1. */
|
||||
const bisZurGrenze = (student) => {
|
||||
/** Takes students to the limit. The baseline provides the starting point. */
|
||||
const bringToCap = (student) => {
|
||||
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.notes.negativ,
|
||||
@@ -47,8 +47,8 @@ describe("Noten API - Sammelpfade", () => {
|
||||
describe("createPruefungen", () => {
|
||||
beforeEach(function () {
|
||||
requireWiederholung(this, ctx);
|
||||
// ohne Anlage der kommissionellen Prüfung meldet die Grenze kommPruefNichtErlaubt
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
// If the commission audit is not attached, the boundary returns “commAuditNotAllowed”
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
});
|
||||
|
||||
it("lehnt nur die Zeile gegen eine §1-Regel ab und nimmt die übrigen an", () => {
|
||||
@@ -59,7 +59,7 @@ describe("Noten API - Sammelpfade", () => {
|
||||
seedBaseline(ctx, atCap);
|
||||
seedBaseline(ctx, fresh);
|
||||
|
||||
const datum = bisZurGrenze(atCap);
|
||||
const datum = bringToCap(atCap);
|
||||
|
||||
notenApi
|
||||
.createPruefungen(
|
||||
@@ -93,13 +93,13 @@ describe("Noten API - Sammelpfade", () => {
|
||||
beforeEach(function () {
|
||||
requireDbReset();
|
||||
requireWiederholung(this, ctx);
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
});
|
||||
|
||||
it("wendet die §1-Regeln je Zeile an", function () {
|
||||
// im Punktemodus leitet der Endpunkt die Note aus den Punkten ab; die Regelprüfung
|
||||
// deckt der punktebasierte Test unten ab
|
||||
requireNotenModus(this, ctx);
|
||||
// In Punkte mode, the endpoint derives the grade from the points; the rule check
|
||||
// is covered by the points-based test below
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
const atCap = ctx.students[0];
|
||||
const fresh = ctx.students[1];
|
||||
@@ -108,7 +108,7 @@ describe("Noten API - Sammelpfade", () => {
|
||||
seedBaseline(ctx, atCap);
|
||||
seedBaseline(ctx, fresh);
|
||||
|
||||
const datum = bisZurGrenze(atCap);
|
||||
const datum = bringToCap(atCap);
|
||||
|
||||
notenApi
|
||||
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
@@ -134,10 +134,10 @@ describe("Noten API - Sammelpfade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Punktemodus: die Note kommt aus dem Notenschlüssel, die Zeile liefert nur Punkte.
|
||||
// Point mode: The grade comes from the grading key; the row only provides points.
|
||||
describe("Punktemodus", () => {
|
||||
beforeEach(function () {
|
||||
requirePunkteModus(this, ctx);
|
||||
requirePunkteMode(this, ctx);
|
||||
});
|
||||
|
||||
it("wendet die Regeln je Zeile auf die abgeleitete Note an", () => {
|
||||
@@ -149,8 +149,8 @@ describe("Noten API - Sammelpfade", () => {
|
||||
seedBaseline(ctx, atCap);
|
||||
seedBaseline(ctx, fresh);
|
||||
|
||||
const datum = bisZurGrenze(atCap);
|
||||
const zeile = (student) => ({
|
||||
const datum = bringToCap(atCap);
|
||||
const bulkRow = (student) => ({
|
||||
uid: student.uid,
|
||||
note: null,
|
||||
punkte,
|
||||
@@ -158,21 +158,25 @@ describe("Noten API - Sammelpfade", () => {
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
});
|
||||
|
||||
notenApi.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [zeile(atCap), zeile(fresh)]).then((response) => {
|
||||
const data = expectNotenSuccess(response, "savePruefungenBulk mit Punkten");
|
||||
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
|
||||
expectBulkRowAccepted(data, fresh.uid);
|
||||
});
|
||||
notenApi
|
||||
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [bulkRow(atCap), bulkRow(fresh)])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "savePruefungenBulk mit Punkten");
|
||||
expectBulkRowError(data, atCap.uid, "maxAntritteReached");
|
||||
expectBulkRowAccepted(data, fresh.uid);
|
||||
});
|
||||
|
||||
notenApi.getNoteByPunkte(punkte, ctx.lvId, ctx.semKurzbz).then((antwort) => {
|
||||
const erwartet = antwort.body.data;
|
||||
notenApi.getNoteByPunkte(punkte, ctx.lvId, ctx.semKurzbz).then((punkteResponse) => {
|
||||
const expectedNote = punkteResponse.body.data;
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const neu = attemptsOfStudent(data, fresh.uid).find(
|
||||
const newTermin = attemptsOfStudent(data, fresh.uid).find(
|
||||
(p) => String(p.datum).slice(0, 10) === datum,
|
||||
);
|
||||
expect(neu, "der neue Termin").to.exist;
|
||||
expect(String(neu.note), "die aus den Punkten abgeleitete Note").to.eq(String(erwartet));
|
||||
expect(newTermin, "der neue Termin").to.exist;
|
||||
expect(String(newTermin.note), "die aus den Punkten abgeleitete Note").to.eq(
|
||||
String(expectedNote),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -181,8 +185,8 @@ describe("Noten API - Sammelpfade", () => {
|
||||
|
||||
describe("saveNotenvorschlagBulk", () => {
|
||||
it("schreibt für jede Zeile eine LV-Note", function () {
|
||||
// im Punktemodus kommt die Note aus dem Notenschlüssel, siehe den Punktemodus-Block
|
||||
requireNotenModus(this, ctx);
|
||||
// In points mode, the grade comes from the grading key, see the score mode block
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
|
||||
@@ -194,7 +198,7 @@ describe("Noten API - Sammelpfade", () => {
|
||||
{ uid: b.uid, note: ctx.gradeNotes[1], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
// nach uid geschlüsselt: je Zeile die LV-Note oder eine Fehlermeldung
|
||||
// Sorted by uid: each line contains either the course grade or an error message
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
expectBulkRowAccepted(data, b.uid);
|
||||
@@ -213,28 +217,28 @@ describe("Noten API - Sammelpfade", () => {
|
||||
|
||||
describe("Punktemodus", () => {
|
||||
beforeEach(function () {
|
||||
requirePunkteModus(this, ctx);
|
||||
requirePunkteMode(this, ctx);
|
||||
});
|
||||
|
||||
it("überspringt eine Zeile ohne Punkte und schreibt die übrigen", () => {
|
||||
const ohnePunkte = ctx.students[0];
|
||||
const mitPunkten = ctx.students[1];
|
||||
const withoutPunkte = ctx.students[0];
|
||||
const withPunkte = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: ohnePunkte.uid, note: null, punkte: null },
|
||||
{ uid: mitPunkten.uid, note: null, punkte: 100 },
|
||||
{ uid: withoutPunkte.uid, note: null, punkte: null },
|
||||
{ uid: withPunkte.uid, note: null, punkte: 100 },
|
||||
])
|
||||
.then((response) => {
|
||||
// eine kaputte Zeile darf den ganzen Import nicht abbrechen
|
||||
// A broken line should not cause the entire import to fail
|
||||
const data = expectNotenSuccess(response, "Notenimport mit Luecke");
|
||||
expectBulkRowError(data, ohnePunkte.uid, "c4punkteKeineNoteErmittelt");
|
||||
expectBulkRowAccepted(data, mitPunkten.uid);
|
||||
expectBulkRowError(data, withoutPunkte.uid, "c4punkteKeineNoteErmittelt");
|
||||
expectBulkRowAccepted(data, withPunkte.uid);
|
||||
});
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, ohnePunkte.uid).then((row) => {
|
||||
readLvGesamtnoteViaDb(ctx, withoutPunkte.uid).then((row) => {
|
||||
expect(row, "die übersprungene Zeile bleibt ungeschrieben").to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Notenfreigabe (P1, cases 12-14).
|
||||
*
|
||||
* Eine erfolgreiche Freigabe verschickt die Freigabemail, solange CIS_GESAMTNOTE_FREIGABEMAIL nicht false
|
||||
* ist. Die Entwicklungsinstanzen stellen jede Mail in ein Debug-Postfach zu (MAIL_DEBUG), deshalb laufen
|
||||
* die Tests mit der Mail. Die Suite sieht die Mail selbst nicht: sie prüft die Vorlagen und die Antwort.
|
||||
* A successful release sends the release email, provided that CIS_GESAMTNOTE_FREIGABEMAIL is not false
|
||||
*. The development instances deliver every email to a debug mailbox (MAIL_DEBUG), which is why
|
||||
* the tests run with the email. The suite does not see the email itself; it checks the templates and the response.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import { notenApi, notenAuth } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import { requireKonfiguration, requireNotenModus } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineBenotungsdatum,
|
||||
@@ -33,7 +31,7 @@ import {
|
||||
verlaufOfStudent,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
|
||||
const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || notenAuth().password;
|
||||
|
||||
/** The payload shape saveStudentenNoten expects per student (see the studlist builder). */
|
||||
const notenPayload = (student, noteBezeichnung) => ({
|
||||
@@ -56,8 +54,8 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
|
||||
describe("Passwortschutz", () => {
|
||||
it("lehnt eine Freigabe mit falschem Passwort ab und ändert nichts", function () {
|
||||
// ohne Passwortpflicht gibt der Aufruf frei
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
|
||||
// Without a password requirement, the call grants access
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
|
||||
requireDbReset();
|
||||
|
||||
const student = ctx.students[0];
|
||||
@@ -115,14 +113,14 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
expect(grades.freigabedatum, "the note is now freigegeben").to.exist;
|
||||
});
|
||||
|
||||
// C1: Die Freigabe brach früher vor dem Schreiben ab.
|
||||
// The release used to abort before writing.
|
||||
readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
|
||||
expect(row.freigabedatum, "C1: die Freigabe schreibt freigabedatum").to.not.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it("legt mit der Freigabe den ersten Antritt an", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -133,7 +131,7 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
|
||||
.then((response) => {
|
||||
const entry = expectNotenSuccess(response, "saveStudentenNoten").find((r) => r.uid === student.uid);
|
||||
// ohne den Verlauf in der Antwort zeigt die Tabelle den neuen Termin erst nach einem Reload
|
||||
// Without the history in the response, the table won't display the new date until it is reloaded
|
||||
expect(entry.verlauf.pruefungen, "der erste Antritt kommt mit der Antwort zurück").to.have.length(
|
||||
1,
|
||||
);
|
||||
@@ -147,7 +145,7 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
|
||||
it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME keinen Termin an", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -168,7 +166,7 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
|
||||
it("gibt ohne Passwort frei, wenn die Konfiguration keines verlangt", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
@@ -188,14 +186,14 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W7: Eine einzelne Wiederholung ist nicht Antritt 1. Die Freigabe überschreibt ihre Note nicht.
|
||||
// A single repeat is not Antritt 1. The approval does not overwrite your grade.
|
||||
it("behält die Note einer einzelnen Wiederholung", () => {
|
||||
const student = ctx.students[1];
|
||||
const g2 = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false });
|
||||
seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), typ: "Termin2" });
|
||||
seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
notenApi
|
||||
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
|
||||
@@ -208,8 +206,8 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Das Datum eines Termins gehört der Person, die ihn eingetragen hat. Die Freigabe macht die
|
||||
// Note verbindlich und verschiebt kein Datum.
|
||||
// The date of an appointment belongs to the person who entered it. Approving it makes the
|
||||
// grade binding and does not change the date.
|
||||
it("behält das Datum einer bestehenden Prüfung", () => {
|
||||
const student = ctx.students[2];
|
||||
const pruefungsdatum = attemptDate(ctx, 2);
|
||||
@@ -221,8 +219,8 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
erstantritt: false,
|
||||
benotungsdatum: baselineBenotungsdatum(ctx),
|
||||
});
|
||||
// Antritt 1 mit einem eigenen Datum; ohne Termin datiert die Freigabe auf das Benotungsdatum
|
||||
seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: pruefungsdatum, typ: "Termin1" });
|
||||
// Antritt 1 with a custom date. if no date is specified, the release date is set to the grading date
|
||||
seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: pruefungsdatum, type: "Termin1" });
|
||||
|
||||
notenApi
|
||||
.saveStudentenNoten(freigabePassword(), [notenPayload(student)], ctx.lvId, ctx.semKurzbz)
|
||||
@@ -284,11 +282,11 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Die Mail geht an ein Debug-Postfach, die Suite liest sie nicht. Sie prüft deshalb, was die Mail braucht:
|
||||
// beide Vorlagen mit Text und eine Freigabe, die mit eingeschalteter Mail vollständig antwortet.
|
||||
// The email is sent to a debug mailbox; the suite does not read it. It therefore checks what the email requires:
|
||||
// both templates with text and an approval that responds in full when email is enabled.
|
||||
describe("Freigabemail", () => {
|
||||
beforeEach(function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABEMAIL", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABEMAIL", true);
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
@@ -324,24 +322,24 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// CIS_GESAMTNOTE_FREIGABE_FINAL: Kein Pfad ändert eine freigegebene Note. Die Tests säen die
|
||||
// Freigabe, deshalb brauchen sie kein Passwort.
|
||||
// CIS_GESAMTNOTE_FREIGABE_FINAL: No path changes a released grade. The tests trigger the
|
||||
// release, so they do not need a password.
|
||||
describe("endgültige Freigabe", () => {
|
||||
beforeEach(function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", true);
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
const andereNote = () => ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
|
||||
const otherNote = () => ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
|
||||
|
||||
it("lehnt eine Übernahme nach der Freigabe ab", () => {
|
||||
const student = ctx.students[0];
|
||||
|
||||
// ausdrücklich freigegeben: unter FINAL sät die Baseline sonst eine offene Note
|
||||
// Explicitly released: otherwise, the baseline seeds an open note under “FINAL”
|
||||
givenBaseline(ctx, student, { freigegeben: true });
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, andereNote())
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, otherNote())
|
||||
.then((response) => expectNotenError(response, "freigabeEndgueltig"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
|
||||
@@ -364,24 +362,24 @@ describe("Noten API - Notenfreigabe", () => {
|
||||
});
|
||||
|
||||
it("lehnt im Import nur die freigegebene Zeile ab", function () {
|
||||
// im Punktemodus leitet der Import die Note aus den Punkten ab
|
||||
requireNotenModus(this, ctx);
|
||||
// Point mode: The grade comes from the grading key; the row only provides points.
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
const [freigegeben, offen] = ctx.students;
|
||||
const [freigegeben, open] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, freigegeben, { freigegeben: true });
|
||||
seedBaseline(ctx, offen, { freigegeben: false });
|
||||
seedBaseline(ctx, open, { freigegeben: false });
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: freigegeben.uid, note: andereNote(), punkte: null },
|
||||
{ uid: offen.uid, note: andereNote(), punkte: null },
|
||||
{ uid: freigegeben.uid, note: otherNote(), punkte: null },
|
||||
{ uid: open.uid, note: otherNote(), punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expectBulkRowError(data, freigegeben.uid, "freigabeEndgueltig");
|
||||
expectBulkRowAccepted(data, offen.uid);
|
||||
expectBulkRowAccepted(data, open.uid);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Prüfungsordnung §1 - Noteneintragungsfrist.
|
||||
* Examination Regulations §1 - Grade Entry Deadline.
|
||||
*
|
||||
* Zwei Fristen beantworten zwei Fragen: CIS_GESAMTNOTE_FRIST_EINGABE sperrt den Zeitpunkt der Eingabe,
|
||||
* CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM das Datum der Prüfung. Die Eingabefrist greift in jedem Schreibpfad
|
||||
* direkt nach der Zugriffsprüfung, ein abgelehnter Request schreibt also nichts. Die Meldung nennt die Frist
|
||||
* und prüft damit ihre Ableitung aus NOTENEINTRAGUNGSFRIST_SS/WS.
|
||||
* Two deadlines address two issues: CIS_GESAMTNOTE_FRIST_EINGABE restricts the time of entry,
|
||||
* CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM restricts the exam date. The entry deadline applies in every write path
|
||||
* immediately after the access check; a rejected request therefore does not write anything. The message specifies the deadline
|
||||
* and uses it to verify its derivation from NOTENEINTRAGUNGSFRIST_SS/WS.
|
||||
*
|
||||
* Die Seeder-Gruppe benotungstool_fixture_erweitert legt das Sommersemester an, in dem der Testbenutzer nach der Frist unterrichtet.
|
||||
* The seeder group benotungstool_fixture_erweitert creates the summer semester in which the test user is notified after the deadline.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
@@ -17,15 +17,15 @@ import {
|
||||
fristHasPassed,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
lehrsemesterMitAbgelaufenerFrist,
|
||||
teachingSemesterWithExpiredFrist,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
|
||||
describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
let ctx;
|
||||
// Semester samt Lehrveranstaltung, in dem der Benutzer unterrichtet und die Frist abgelaufen ist
|
||||
const abgelaufen = { SS: null, WS: null };
|
||||
// Semester, including the course, in which the user teaches and the deadline has passed
|
||||
const expired = { SS: null, WS: null };
|
||||
|
||||
const fristSS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_SS;
|
||||
const fristWS = () => ctx.cisConfig.NOTENEINTRAGUNGSFRIST_WS;
|
||||
@@ -39,49 +39,49 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
`AUSNAHME_GILT=${ctx.cisConfig.CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT}`,
|
||||
);
|
||||
|
||||
["SS", "WS"].forEach((typ) =>
|
||||
lehrsemesterMitAbgelaufenerFrist(typ, fristSS(), fristWS()).then((paar) => {
|
||||
abgelaufen[typ] = paar;
|
||||
cy.log(`${typ} mit abgelaufener Frist: ${JSON.stringify(paar)}`);
|
||||
["SS", "WS"].forEach((type) =>
|
||||
teachingSemesterWithExpiredFrist(type, fristSS(), fristWS()).then((pair) => {
|
||||
expired[type] = pair;
|
||||
cy.log(`${type} mit abgelaufener Frist: ${JSON.stringify(pair)}`);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ein fehlendes Semester ist ein Fixturefehler und kein übersprungener Test
|
||||
const paarFuer = (typ) => {
|
||||
expect(abgelaufen[typ], `ein ${typ} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be
|
||||
// A missing semester is a fixture error, not a skipped test
|
||||
const pairFor = (type) => {
|
||||
expect(expired[type], `ein ${type} mit abgelaufener Frist, in dem der Testbenutzer unterrichtet`).to.not.be
|
||||
.null;
|
||||
return abgelaufen[typ];
|
||||
return expired[type];
|
||||
};
|
||||
|
||||
// §7 und §11: Die kommissionelle Prüfung muss bis zur Frist STATTFINDEN. Das ist eine andere Frage als
|
||||
// "darf jetzt noch eingetragen werden".
|
||||
// §7 and §11: The commission review must TAKE PLACE by the deadline. That is a different question from
|
||||
// “Can it still be registered now?”
|
||||
describe("Prüfungsdatum nach der Frist", () => {
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
// die Frist des laufenden Semesters liegt vor diesem Datum
|
||||
const nachDerFrist = () => `${Number(ctx.semKurzbz.slice(2, 6)) + 1}-12-31`;
|
||||
// The deadline for the current semester is before this date
|
||||
const afterFrist = () => `${Number(ctx.semKurzbz.slice(2, 6)) + 1}-12-31`;
|
||||
|
||||
it("lehnt einen Termin ab, der nach der Frist liegt", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nachDerFrist() }).then((response) =>
|
||||
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: afterFrist() }).then((response) =>
|
||||
expectNotenError(response, "pruefungsdatumNachFrist"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt einen Termin nach der Frist an, wenn das Prüfungsdatum an keine Frist gebunden ist", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_PRUEFUNGSDATUM", false);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: nachDerFrist() }).then((response) =>
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: afterFrist() }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin nach der Frist"),
|
||||
);
|
||||
});
|
||||
@@ -101,35 +101,35 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
describe("Eingabe nach der Frist", () => {
|
||||
const s0 = () => ctx.students[0];
|
||||
|
||||
// Der Studierende gehört nicht zur Lehrveranstaltung des abgelaufenen Semesters. Greift die Eingabefrist
|
||||
// nicht, lehnt die Teilnehmerprüfung ab, und der Request schreibt trotzdem nichts.
|
||||
const schreibpfade = {
|
||||
saveStudentPruefung: (paar) =>
|
||||
// The student is not enrolled in the course from the previous semester. If the submission deadline
|
||||
// does not apply, the system rejects the student's attempt, and the request still does not write anything.
|
||||
const writePaths = {
|
||||
saveStudentPruefung: (pair) =>
|
||||
notenApi.saveStudentPruefung({
|
||||
student_uid: s0().uid,
|
||||
note: ctx.gradeNotes[0],
|
||||
punkte: null,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lva_id: paar.lvId,
|
||||
lva_id: pair.lvId,
|
||||
lehreinheit_id: s0().lehreinheit_id,
|
||||
sem_kurzbz: paar.semKurzbz,
|
||||
sem_kurzbz: pair.semKurzbz,
|
||||
pruefung_id: null,
|
||||
}),
|
||||
saveNotenvorschlag: (paar) =>
|
||||
notenApi.saveNotenvorschlag(paar.lvId, paar.semKurzbz, s0().uid, ctx.gradeNotes[0]),
|
||||
saveNotenvorschlagBulk: (paar) =>
|
||||
notenApi.saveNotenvorschlagBulk(paar.lvId, paar.semKurzbz, [
|
||||
saveNotenvorschlag: (pair) =>
|
||||
notenApi.saveNotenvorschlag(pair.lvId, pair.semKurzbz, s0().uid, ctx.gradeNotes[0]),
|
||||
saveNotenvorschlagBulk: (pair) =>
|
||||
notenApi.saveNotenvorschlagBulk(pair.lvId, pair.semKurzbz, [
|
||||
{ uid: s0().uid, note: ctx.gradeNotes[0], punkte: null },
|
||||
]),
|
||||
createPruefungen: (paar) =>
|
||||
createPruefungen: (pair) =>
|
||||
notenApi.createPruefungen(
|
||||
[{ uid: s0().uid, lehreinheit_id: s0().lehreinheit_id }],
|
||||
attemptDate(ctx, 1),
|
||||
paar.lvId,
|
||||
paar.semKurzbz,
|
||||
pair.lvId,
|
||||
pair.semKurzbz,
|
||||
),
|
||||
savePruefungenBulk: (paar) =>
|
||||
notenApi.savePruefungenBulk(paar.lvId, paar.semKurzbz, [
|
||||
savePruefungenBulk: (pair) =>
|
||||
notenApi.savePruefungenBulk(pair.lvId, pair.semKurzbz, [
|
||||
{
|
||||
uid: s0().uid,
|
||||
note: ctx.gradeNotes[0],
|
||||
@@ -140,49 +140,49 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
|
||||
]),
|
||||
};
|
||||
|
||||
const meldetFrist = (response) =>
|
||||
const reportsFrist = (response) =>
|
||||
((response.body && response.body.errors) || []).some((e) =>
|
||||
messageMatchesPhrase(e.message, "noteneintragungsfristVorbei"),
|
||||
);
|
||||
|
||||
Object.entries(schreibpfade).forEach(([pfad, aufruf]) => {
|
||||
["SS", "WS"].forEach((typ) => {
|
||||
it(`${pfad} lehnt eine Eingabe im ${typ} nach der Frist ab`, function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false);
|
||||
Object.entries(writePaths).forEach(([writePath, call]) => {
|
||||
["SS", "WS"].forEach((type) => {
|
||||
it(`${writePath} lehnt eine Eingabe im ${type} nach der Frist ab`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", false);
|
||||
|
||||
const paar = paarFuer(typ);
|
||||
const pair = pairFor(type);
|
||||
expect(
|
||||
fristHasPassed(paar.semKurzbz, fristSS(), fristWS()),
|
||||
`die Frist von ${paar.semKurzbz} ist vorbei`,
|
||||
fristHasPassed(pair.semKurzbz, fristSS(), fristWS()),
|
||||
`die Frist von ${pair.semKurzbz} ist vorbei`,
|
||||
).to.be.true;
|
||||
|
||||
aufruf(paar).then((response) => {
|
||||
call(pair).then((response) => {
|
||||
expectNotenError(response, "noteneintragungsfristVorbei");
|
||||
|
||||
const meldung = response.body.errors.map((e) => e.message).join(" | ");
|
||||
expect(meldung, `die Frist, die der Server für ${paar.semKurzbz} ableitet`).to.include(
|
||||
expectedFristString(paar.semKurzbz, fristSS(), fristWS()),
|
||||
const errorMessage = response.body.errors.map((e) => e.message).join(" | ");
|
||||
expect(errorMessage, `die Frist, die der Server für ${pair.semKurzbz} ableitet`).to.include(
|
||||
expectedFristString(pair.semKurzbz, fristSS(), fristWS()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Die Ausnahme deckt die EINGABE ab, nicht das Prüfungsdatum.
|
||||
it(`${pfad} lässt eine Ausnahmerolle nach der Frist eintragen`, function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", true);
|
||||
// The exception covers the INPUT DATE, not the exam date.
|
||||
it(`${writePath} lässt eine Ausnahmerolle nach der Frist eintragen`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT", true);
|
||||
|
||||
aufruf(paarFuer("SS")).then((response) => {
|
||||
expect(meldetFrist(response), "die Eingabefrist greift nicht").to.be.false;
|
||||
call(pairFor("SS")).then((response) => {
|
||||
expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it(`${pfad} nimmt ohne Eingabefrist eine späte Eingabe an`, function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", false);
|
||||
it(`${writePath} nimmt ohne Eingabefrist eine späte Eingabe an`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FRIST_EINGABE", false);
|
||||
|
||||
aufruf(paarFuer("SS")).then((response) => {
|
||||
expect(meldetFrist(response), "die Eingabefrist greift nicht").to.be.false;
|
||||
call(pairFor("SS")).then((response) => {
|
||||
expect(reportsFrist(response), "die Eingabefrist greift nicht").to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* When a grade closes the attempt chain.
|
||||
*
|
||||
* negative -> repeat allowed
|
||||
* positive, not final -> only with CIS_GESAMTNOTE_NOTENVERBESSERUNG
|
||||
* final -> chain closed, always
|
||||
*
|
||||
* tbl_note.positiv alone is not the rule: 'entschuldigt' and 'Teilgenommen' carry it too. Only the
|
||||
* grade of a Termin that uses an attempt decides.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
attemptsOfStudent,
|
||||
editPruefung,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
verlaufOfStudent,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
import { requireConfig, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
|
||||
describe("Noten API - wann eine Note die Kette schliesst", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
cy.log(`NOTEN_ABSCHLIESSEND = ${JSON.stringify(ctx.cisConfig.NOTEN_ABSCHLIESSEND)}`);
|
||||
cy.log(`NOTENVERBESSERUNG = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTENVERBESSERUNG}`);
|
||||
cy.log(`VERBESSERUNG_BESSERE_GEWINNT = ${ctx.cisConfig.CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT}`);
|
||||
cy.log(`notes.negativ=${ctx.notes.negativ} positiv=${ctx.notes.positiv} bestnote=${ctx.notes.bestnote}`);
|
||||
});
|
||||
});
|
||||
|
||||
const studentFor = (index) => {
|
||||
const student = ctx.students[index];
|
||||
expect(student, `student at index ${index}`).to.exist;
|
||||
return student;
|
||||
};
|
||||
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
it("lässt die Kette nach einer negativen Note offen", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.negativ });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "Wiederholung nach negativer Note"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const verlauf = verlaufOfStudent(data, student.uid);
|
||||
expect(verlauf.bestanden, "eine negative Note ist kein Abschluss").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it("schliesst die Kette nach einer abschliessenden Note", function () {
|
||||
skipIf(this, ctx.notes.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala.");
|
||||
|
||||
const student = studentFor(1);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.bestnote });
|
||||
|
||||
readStateViaApi(ctx)
|
||||
.then((data) => {
|
||||
const verlauf = verlaufOfStudent(data, student.uid);
|
||||
expect(verlauf.bestanden, "die Bestnote schliesst ab").to.be.true;
|
||||
expect(verlauf.canAdd, "kein weiterer Termin nach der Bestnote").to.be.false;
|
||||
})
|
||||
.then(() => addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }))
|
||||
.then((response) => expectNotenError(response, "pruefungNachBestandenerNote"));
|
||||
});
|
||||
|
||||
// The top grade ALWAYS ends the chain, even when the improvement feature is enabled.
|
||||
it("schliesst die Kette nach der Bestnote auch bei erlaubter Verbesserung", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
skipIf(this, ctx.notes.bestnote === null, "Übersprungen: keine Note aus NOTEN_ABSCHLIESSEND in der 1-5-Skala.");
|
||||
|
||||
const student = studentFor(2);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.bestnote });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "pruefungNachBestandenerNote"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("eine positive Note ausserhalb NOTEN_ABSCHLIESSEND", () => {
|
||||
beforeEach(function () {
|
||||
skipIf(
|
||||
this,
|
||||
ctx.notes.positiv === null,
|
||||
"Übersprungen: keine positive Note ausserhalb NOTEN_ABSCHLIESSEND.",
|
||||
);
|
||||
});
|
||||
|
||||
it("schliesst die Kette ohne Notenverbesserung", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false);
|
||||
|
||||
const student = studentFor(3);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.positiv });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notes.positiv, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "pruefungNachBestandenerNote"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt mit Notenverbesserung einen weiteren Antritt zu", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
|
||||
const student = studentFor(3);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.positiv });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notes.positiv, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Verbesserung nach positiver Note"),
|
||||
);
|
||||
});
|
||||
|
||||
// An attempt to improve a grade must not lower the course grade if configured to do so.
|
||||
it("behält bei einer schlechteren Verbesserung die bessere LV-Note", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", true);
|
||||
|
||||
const student = studentFor(4);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.positiv });
|
||||
|
||||
// The improvement is less significant than the first attempt
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "schlechtere Verbesserung"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die LV-Note behält die bessere Note").to.eq(String(ctx.notes.positiv));
|
||||
});
|
||||
|
||||
// The exam itself reflects the grade the student actually received
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const attempts = attemptsOfStudent(data, student.uid);
|
||||
const latest = attempts[attempts.length - 1];
|
||||
expect(String(latest.note), "der Termin trägt die echte Note").to.eq(String(ctx.notes.negativ));
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt bei einer schlechteren Verbesserung die letzte Note", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT", false);
|
||||
|
||||
const student = studentFor(4);
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.positiv });
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "schlechtere Verbesserung"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student.uid))
|
||||
.then((row) => {
|
||||
expect(String(row.note), "die LV-Note folgt dem letzten Antritt").to.eq(String(ctx.notes.negativ));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Only one ungraded appointment per course. One graded appointment following the open appointment is still permitted.
|
||||
describe("nur ein Termin ohne Note", () => {
|
||||
beforeEach(function () {
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
const open = () => ctx.notes.nochNichtEingetragen;
|
||||
|
||||
const openTermine = (data, student) =>
|
||||
attemptsOfStudent(data, student.uid).filter((p) => String(p.note) === String(open()));
|
||||
|
||||
it("lehnt einen zweiten Termin ohne Note ab", () => {
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: open(), datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "erster offener Termin"),
|
||||
);
|
||||
addPruefung(ctx, student, { note: open(), datum: attemptDate(ctx, 2) }).then((response) =>
|
||||
expectNotenError(response, "pruefungOhneErgebnis"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(openTermine(data, student), "offene Termine").to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt im Sammeldialog je Zeile einen zweiten offenen Termin ab", () => {
|
||||
const students = [studentFor(0), studentFor(1)];
|
||||
const bulkRows = students.map((s) => ({ uid: s.uid, lehreinheit_id: s.lehreinheit_id }));
|
||||
|
||||
resetNotenState(ctx);
|
||||
students.forEach((s) => seedBaseline(ctx, s));
|
||||
|
||||
notenApi.createPruefungen(bulkRows, attemptDate(ctx, 1), ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
const data = expectNotenSuccess(response, "erster Sammeltermin ohne Note");
|
||||
students.forEach((s) => expectBulkRowAccepted(data, s.uid));
|
||||
});
|
||||
|
||||
notenApi.createPruefungen(bulkRows, attemptDate(ctx, 2), ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
const data = expectNotenSuccess(response, "zweiter Sammeltermin ohne Note");
|
||||
students.forEach((s) => expectBulkRowError(data, s.uid, "pruefungOhneErgebnis"));
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt eine Änderung auf 'Noch nicht eingetragen' neben einem offenen Termin ab", () => {
|
||||
const student = studentFor(2);
|
||||
let latest;
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: open(), datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "offener Termin"),
|
||||
);
|
||||
|
||||
// User's decision: An appointment with a grade following the open appointment is still allowed.
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 2) }).then((response) => {
|
||||
[latest] = expectNotenSuccess(response, "Termin mit Note nach dem offenen Termin");
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, student, {
|
||||
pruefungId: latest.pruefung_id,
|
||||
note: open(),
|
||||
datum: attemptDate(ctx, 2),
|
||||
}),
|
||||
).then((response) => expectNotenError(response, "pruefungOhneErgebnis"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Two concurrent requests for the same student in the same course.
|
||||
*
|
||||
* Grades::lockStudent places a transaction-wide advisory lock. The second request waits and
|
||||
* then reads the state after the first commit. A single run proves little in the event of a race condition,
|
||||
* so each test repeats the process.
|
||||
*/
|
||||
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { attemptsOfStudent, givenBaseline, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const ROUNDS = 5;
|
||||
|
||||
const concurrently = (path, bodies) => cy.task("noten:http:parallel", { path, bodies });
|
||||
|
||||
describe("Noten API - gleichzeitige Schreibvorgänge", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
});
|
||||
});
|
||||
|
||||
it("legt bei zwei gleichzeitigen Terminen am selben Tag nur einen an", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const datum = attemptDate(ctx, 1);
|
||||
|
||||
// ‘entschuldigt’ does not count as an antritt. Therefore, after the first commit, only the
|
||||
// date rule rejects the second date, regardless of maxAppearances and the committee rule.
|
||||
const body = {
|
||||
student_uid: student.uid,
|
||||
note: ctx.notes.entschuldigt,
|
||||
punkte: null,
|
||||
datum,
|
||||
lva_id: ctx.lvId,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
pruefung_id: null,
|
||||
};
|
||||
|
||||
Cypress._.times(ROUNDS, (i) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
concurrently("saveStudentPruefung", [body, body]).then((responses) => {
|
||||
const [accepted, rejected] = [...responses].sort((x, y) => x.status - y.status);
|
||||
expectNotenSuccess(accepted, `Durchlauf ${i + 1}: erster Request`);
|
||||
expectNotenError(rejected, "pruefungDatumBeforeExisting");
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const onDay = attemptsOfStudent(data, student.uid).filter(
|
||||
(p) => String(p.datum).slice(0, 10) === datum,
|
||||
);
|
||||
expect(onDay, `Durchlauf ${i + 1}: Termine am ${datum}`).to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt bei zwei gleichzeitigen Übernahmen nur einen Antritt 1", function () {
|
||||
// Without an initial call, the transfer does not set a time that could cause a race condition
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
const body = (note) => ({
|
||||
lv_id: ctx.lvId,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
student_uid: student.uid,
|
||||
note,
|
||||
punkte: null,
|
||||
datum: baselineDate(ctx),
|
||||
});
|
||||
|
||||
Cypress._.times(ROUNDS, (i) => {
|
||||
resetNotenState(ctx);
|
||||
|
||||
concurrently("saveNotenvorschlag", [body(ctx.gradeNotes[0]), body(ctx.gradeNotes[1])]).then((responses) => {
|
||||
responses.forEach((response, n) =>
|
||||
expectNotenSuccess(response, `Durchlauf ${i + 1}, Request ${n + 1}`),
|
||||
);
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(attemptsOfStudent(data, student.uid), `Durchlauf ${i + 1}: Antritte`).to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Notenschlüssel - Punkte zu Note (P0, case 10). Read-only.
|
||||
* Grade key - Points per grade. Read-only.
|
||||
*
|
||||
* Statt die Zuordnung nachzubauen, wird der Bereich abgetastet und auf die Eigenschaften einer
|
||||
* korrekten Skala geprüft: lückenlos, monoton, exakt an jeder Grenze.
|
||||
* Instead of recreating the mapping, the range is scanned and checked for the properties of a
|
||||
* valid scale: continuous, monotonic, and exact at every boundary.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requirePunkteModus, skipWenn } from "../../../../support/helpers/notenConfig";
|
||||
import { requirePunkteMode, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
@@ -54,8 +54,8 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
`for every value in 0..${MAX_PUNKTE}. Pin NOTEN_LV_ID to an LV that has one.`,
|
||||
).to.be.greaterThan(0);
|
||||
|
||||
// unter der niedrigsten Schwelle antwortet das Modell null; das muss ein zusammenhängender
|
||||
// Anfang sein, ein null NACH einer Note wäre eine Lücke in der Skala
|
||||
// Below the lowest threshold, the model returns zero, this must be a continuous
|
||||
// start. a zero AFTER a note would create a gap in the scale
|
||||
const firstGradedIndex = observed.findIndex((e) => e.note !== null && e.note !== undefined);
|
||||
observed.slice(firstGradedIndex).forEach((entry) => {
|
||||
expect(
|
||||
@@ -64,7 +64,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
).to.not.be.oneOf([null, undefined]);
|
||||
});
|
||||
|
||||
// monoton: die PKs laufen 1 (beste) .. 5, die Zahl darf also nie steigen
|
||||
// Monotonic: The PKs range from 1 (best) to 5, so the number must never increase
|
||||
for (let i = 1; i < graded.length; i += 1) {
|
||||
expect(
|
||||
Number(graded[i].note),
|
||||
@@ -72,7 +72,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
).to.be.at.most(Number(graded[i - 1].note));
|
||||
}
|
||||
|
||||
// jede Stufe einzeln melden, damit eine verschobene Schwelle in der Ausgabe sichtbar wird
|
||||
// Report each level individually so that a shifted threshold is visible in the output
|
||||
const boundaries = [];
|
||||
for (let i = 1; i < graded.length; i += 1) {
|
||||
if (Number(graded[i].note) !== Number(graded[i - 1].note)) {
|
||||
@@ -111,24 +111,24 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W3: Punkte ohne ableitbare Note lehnen alle Pfade gleich ab.
|
||||
// Nodes without a derivable grade reject all paths equally.
|
||||
describe("Punkte ohne ableitbare Note", () => {
|
||||
let schwelleUnterNull = false;
|
||||
let thresholdBelowZero = false;
|
||||
|
||||
before(() => {
|
||||
notenApi.getNoteByPunkte(-1, lvId, ctx.semKurzbz).then((response) => {
|
||||
const note = expectNotenSuccess(response, "getNoteByPunkte(-1)");
|
||||
schwelleUnterNull = note !== null && note !== undefined;
|
||||
thresholdBelowZero = note !== null && note !== undefined;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
requirePunkteModus(this, ctx);
|
||||
skipWenn(this, schwelleUnterNull, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten.");
|
||||
requirePunkteMode(this, ctx);
|
||||
skipIf(this, thresholdBelowZero, "Übersprungen: der Notenschlüssel hat eine Schwelle unter 0 Punkten.");
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
const expectNurAntritt1 = (students) =>
|
||||
const expectOnlyAntritt1 = (students) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
students.forEach((s) => {
|
||||
expect(attemptsOfStudent(data, s.uid), `Prüfungen von ${s.uid}`).to.have.length(1);
|
||||
@@ -153,7 +153,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
})
|
||||
.then((response) => expectNotenError(response, "c4punkteKeineNoteErmittelt"));
|
||||
|
||||
expectNurAntritt1([student]);
|
||||
expectOnlyAntritt1([student]);
|
||||
});
|
||||
|
||||
it("createPruefungen lehnt negative Punkte für alle gewählten Studierenden ab", () => {
|
||||
@@ -173,7 +173,7 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => {
|
||||
)
|
||||
.then((response) => expectNotenError(response, "c4punkteKeineNoteErmittelt"));
|
||||
|
||||
expectNurAntritt1(students);
|
||||
expectOnlyAntritt1(students);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Notenvorschlag - LV-Note schreiben und der resultierende Status (P1, case 11).
|
||||
* Grade proposal - Entering a course grade and the resulting status.
|
||||
*
|
||||
* Status aus zwei Zeitstempeln (notenRules.js::checkFreigabe). Der "changed nach Freigabe"-Fall
|
||||
* wird über eine bereits freigegebene Baseline erzeugt, damit dieser Spec ohne LDAP-Passwort und
|
||||
* ohne Freigabemail auskommt.
|
||||
* Status determined by two timestamps (notenRules.js::checkFreigabe). The “changed after approval” case
|
||||
* is generated using a baseline that has already been approved, so that this spec does not require an LDAP password or
|
||||
* an approval email.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineBenotungsdatum,
|
||||
@@ -82,16 +82,16 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// `erstantritt: false` ist die Altdatenform: freigegebene LV-Note ohne Prüfungszeile. Nur dort
|
||||
// ist ein direktes Umbenoten erlaubt - existiert eine Prüfung, lehnt der Server ab
|
||||
// (validateNotenvorschlag, siehe noten.ueberschreiben).
|
||||
// Das Datum kommt aus dem Übernehmen-Dialog. Die Freigabe macht daraus das Datum von Antritt 1,
|
||||
// daher gelten dieselben Grenzen wie für einen Prüfungstermin.
|
||||
// `erstantritt: false` is the legacy data format: approved course grade without an exam entry. Only in this case
|
||||
// is direct grade adjustment permitted—if an exam exists, the server rejects the request
|
||||
// (validateGradeSuggestion, see overwriteGrades).
|
||||
// The date comes from the “Apply” dialog. The approval process uses this as the date of the first attempt,
|
||||
// so the same restrictions apply as for an exam date.
|
||||
describe("das gewählte Benotungsdatum", () => {
|
||||
// Die LV-Note IST Antritt 1. Ohne diese Zeile bekäme die nächste Prüfung Termin2, und der
|
||||
// Legacy-Typ der ganzen Kette verschiebt sich um eine Stelle.
|
||||
// The lv-note IS “Antritt 1.” Without this line, the next exam would be assigned “Termin2,” and the
|
||||
// legacy type of the entire chain would shift by one position.
|
||||
it("schreibt Antritt 1 mit dem gewählten Tag", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
const datum = baselineDate(ctx);
|
||||
@@ -139,7 +139,7 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
|
||||
it("schreibt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME nur die LV-Note", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
@@ -157,11 +157,11 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
expect(attemptsOfStudent(data, student.uid), "kein Termin").to.have.length(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Ohne Erstantritt bei der Übernahme bleibt die LV-Note Antritt 1. Der erste Termin schreibt ihn nach
|
||||
// und wird selbst Antritt 2.
|
||||
it("legt ohne CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME Antritt 1 mit dem ersten Termin an", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", false);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[2];
|
||||
@@ -188,14 +188,14 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W5: Eine Abfrage liest alle LV-Noten der LV. Das Ergebnis je Studierendem bleibt gleich.
|
||||
// Eine Abfrage liest alle LV-Noten der LV. Das Ergebnis je Studierendem bleibt gleich.
|
||||
describe("Lesedaten", () => {
|
||||
it("liefert LV-Note und Zeitstempel je Studierendem", () => {
|
||||
const [freigegeben, offen, ohneNote] = ctx.students;
|
||||
const [freigegeben, open, withoutNote] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, freigegeben, { freigegeben: true });
|
||||
seedBaseline(ctx, offen, { freigegeben: false });
|
||||
seedBaseline(ctx, open, { freigegeben: false });
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const f = lvNoteOf(data, freigegeben.uid);
|
||||
@@ -203,17 +203,17 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
expect(f.freigabedatum, "freigabedatum der freigegebenen Zeile").to.exist;
|
||||
expect(f.benotungsdatum, "benotungsdatum der freigegebenen Zeile").to.exist;
|
||||
|
||||
const o = lvNoteOf(data, offen.uid);
|
||||
const o = lvNoteOf(data, open.uid);
|
||||
expect(o.note_lv, "LV-Note der offenen Zeile").to.exist;
|
||||
expect(o.benotungsdatum, "benotungsdatum der offenen Zeile").to.exist;
|
||||
expect(o.freigabedatum, "die offene Zeile ist nicht freigegeben").to.be.oneOf([null, undefined, ""]);
|
||||
|
||||
expect((lvNoteOf(data, ohneNote.uid) || {}).note_lv, "keine LV-Note").to.be.oneOf([null, undefined]);
|
||||
expect((lvNoteOf(data, withoutNote.uid) || {}).note_lv, "keine LV-Note").to.be.oneOf([null, undefined]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// W7: Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Dieses
|
||||
// Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Dieses
|
||||
// Werkzeug erzeugt den Zustand nicht mehr: sein erster Termin schreibt Antritt 1 nach.
|
||||
describe("eine einzelne Wiederholung", () => {
|
||||
it("sperrt die Übernahme", () => {
|
||||
@@ -228,7 +228,7 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
seedBaseline(ctx, student, { erstantritt: false });
|
||||
|
||||
let wiederholungId;
|
||||
seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), typ: "Termin2" }).then((seeded) => {
|
||||
seedPruefung(ctx, student, { note: g2, datum: attemptDate(ctx, 1), type: "Termin2" }).then((seeded) => {
|
||||
wiederholungId = seeded.pruefungId;
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ describe("Noten API - Notenvorschlag", () => {
|
||||
describe("eine bereits freigegebene Note neu benoten", () => {
|
||||
// eine endgültige Freigabe verbietet genau das, siehe noten.freigabe
|
||||
beforeEach(function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
|
||||
});
|
||||
|
||||
it("wechselt den Status von freigegeben auf geändert", () => {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Prüfungsordnung §1 - Prüfungsantritte.
|
||||
* Examination Regulations §1 - Taking Exams.
|
||||
*
|
||||
* Die Regeln greifen in der Reihenfolge A (Maximum) -> B (Chronologie) -> C (Vorkommenslimit).
|
||||
* Jeder Test baut einen Zustand, in dem genau eine davon feuern kann, sonst maskiert die frühere.
|
||||
* The rules apply in the following order: A (Maximum) -> B (Chronological) -> C (Occurrence Limit).
|
||||
* Each test establishes a state in which exactly one of these can trigger; otherwise, the earlier one takes precedence.
|
||||
*
|
||||
* Gezählt wird über die NOTE: entschuldigt, "Noch nicht eingetragen" und "Nicht beurteilt" zählen
|
||||
* nie mit. Antritt 1 ist eine echte Prüfung (von givenBaseline geseedet).
|
||||
* The count is based on the GRADE: entschuldigt, “Noch nicht eingetragen” are
|
||||
* never counted. Attempt 1 is a real exam (seeded by givenBaseline).
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
requireKommissionellerAntritt,
|
||||
requireKonfiguration,
|
||||
requireConfig,
|
||||
requireWiederholung,
|
||||
skipWenn,
|
||||
skipIf,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
@@ -55,9 +55,9 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
// one student per test, so a leaked row cannot reach the next scenario
|
||||
const studentFor = (index) => ctx.students[index % ctx.students.length];
|
||||
|
||||
/** Skip, wenn der letzte Antritt der Kette nicht der kommissionelle ist. */
|
||||
const skipOhneKommissionLetzt = (test) =>
|
||||
skipWenn(
|
||||
/** Skip if the last Antritt in the chain is not the commission link. */
|
||||
const skipUnlessLastKommissionell = (test) =>
|
||||
skipIf(
|
||||
test,
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
|
||||
"Übersprungen: der letzte Antritt ist nicht kommissionell.",
|
||||
@@ -76,9 +76,9 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
return attemptDate(ctx, firstIndex + ctx.maxAntritte - 1);
|
||||
};
|
||||
|
||||
// Die Konfiguration nennt die Sondernoten nur mit ihrer Bezeichnung. Die API antwortet mit dem
|
||||
// aufgelösten Primärschlüssel, auf Demodaten ist entschuldigt die 14. Scheitert die Auflösung,
|
||||
// greifen alle Regeln stillschweigend auf der falschen Note.
|
||||
// The configuration refers to the special notes only by their labels. The API responds with the
|
||||
// resolved primary key; on demo data, this is set to 14 by default. If the resolution fails,
|
||||
// all rules implicitly apply to the incorrect note.
|
||||
it("löst die Sondernoten über ihre Bezeichnung aus tbl_note auf", () => {
|
||||
notenApi.getCisConfig().then((response) => {
|
||||
const config = expectNotenSuccess(response, "getCisConfig");
|
||||
@@ -98,7 +98,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
describe("Regel A - die Höchstzahl der Prüfungsantritte", () => {
|
||||
it("lehnt einen Antritt ab, sobald das konfigurierte Maximum erreicht ist", function () {
|
||||
// ohne Anlage der kommissionellen Prüfung endet die Kette früher, siehe den Test zu kommPruefNichtErlaubt
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", true);
|
||||
|
||||
const student = studentFor(0);
|
||||
|
||||
@@ -157,7 +157,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Regel C - 'entschuldigt' gilt nur einmal", () => {
|
||||
describe("'entschuldigt' gilt nur einmal", () => {
|
||||
beforeEach(function () {
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
@@ -216,18 +216,18 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
});
|
||||
|
||||
describe("die kommissionelle Prüfung ist der letzte Antritt", () => {
|
||||
// §17 Abs 1: die zweite Wiederholung ist kommissionell. Der Server leitet den Typ aus der
|
||||
// Position ab und schreibt ihn in pruefungstyp_kurzbz, weil die Studierendenverwaltung
|
||||
// diese Spalte weiterhin liest.
|
||||
// §17, Abs 1: The second retake is kommissionell. The server derives the type from the
|
||||
// position and writes it to `pruefungstyp_kurzbz` because the student administration
|
||||
// system still reads this column.
|
||||
it("schreibt den letzten Antritt als kommissionelle Prüfung", function () {
|
||||
requireKommissionellerAntritt(this, ctx);
|
||||
skipOhneKommissionLetzt(this);
|
||||
skipUnlessLastKommissionell(this);
|
||||
|
||||
const student = studentFor(3);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// bis zum vorletzten Antritt auffüllen
|
||||
// Fill up to the second-to-last step
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, i) }).then((response) =>
|
||||
expectNotenSuccess(response, `Antritt ${i + 1}`),
|
||||
@@ -250,18 +250,16 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
expect(verlauf.canAdd, "danach ist kein Antritt mehr möglich").to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
// Die Anlage kann abgeschaltet sein, wenn eine Installation die kommissionelle Prüfung in
|
||||
// einem anderen Werkzeug einträgt. Angezeigt wird sie trotzdem, nur angelegt nicht.
|
||||
|
||||
it("verweigert den letzten Antritt, wenn das Tool ihn nicht anlegen darf", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", false);
|
||||
skipOhneKommissionLetzt(this);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF", false);
|
||||
skipUnlessLastKommissionell(this);
|
||||
|
||||
const student = studentFor(4);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// bis zum vorletzten Antritt auffüllen; danach wäre der kommissionelle fällig
|
||||
// Fill up to the second-to-last step
|
||||
for (let i = 1; i < ctx.maxAntritte - 1; i += 1) {
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, i) }).then((response) =>
|
||||
expectNotenSuccess(response, `Antritt ${i + 1}`),
|
||||
@@ -280,8 +278,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
}).then((response) => expectNotenError(response, "kommPruefNichtErlaubt"));
|
||||
});
|
||||
|
||||
// Auch eine kommissionelle Prüfung ohne zählende Note schliesst die Kette. Sonst liesse sich
|
||||
// nach einer noch unbenoteten kommissionellen ein weiterer Antritt anlegen.
|
||||
// Even a kommissionelle pruefung without a graded score completes the chain. Otherwise,
|
||||
// it would be possible to schedule another attempt after a committee exam that has not yet been graded.
|
||||
it("sperrt weitere Antritte auch bei einer kommPruef ohne zählende Note", () => {
|
||||
const student = studentFor(1);
|
||||
|
||||
@@ -290,7 +288,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
seedPruefung(ctx, student, {
|
||||
note: ctx.notes.nochNichtEingetragen,
|
||||
datum: attemptDate(ctx, 1),
|
||||
typ: "kommPruef",
|
||||
type: "kommPruef",
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
@@ -319,11 +317,11 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const offen = attemptsOfStudent(data, student.uid).find(
|
||||
const open = attemptsOfStudent(data, student.uid).find(
|
||||
(p) => String(p.note) === String(ctx.notes.nochNichtEingetragen),
|
||||
);
|
||||
expect(offen, "der offene Termin").to.exist;
|
||||
expect(offen.zaehlt, "der offene Termin verbraucht keinen Antritt").to.be.false;
|
||||
expect(open, "der offene Termin").to.exist;
|
||||
expect(open.zaehlt, "der offene Termin verbraucht keinen Antritt").to.be.false;
|
||||
|
||||
const verlauf = verlaufOfStudent(data, student.uid);
|
||||
expect(verlauf.antrittCount, "nur Antritt 1").to.eq(1);
|
||||
@@ -335,7 +333,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
describe("Anrechnung - gar keine Prüfungen", () => {
|
||||
it("lehnt einen Antritt bei angerechneter Zeugnisnote ab", function () {
|
||||
const angerechnet = (ctx.cisConfig.NOTEN_ANRECHNUNG || [])[0];
|
||||
skipWenn(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf.");
|
||||
skipIf(this, angerechnet === undefined, "Übersprungen: NOTEN_ANRECHNUNG löst keine Note auf.");
|
||||
|
||||
const student = studentFor(4);
|
||||
|
||||
@@ -359,7 +357,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
/** Antritt 1 < entschuldigt < echte Note; liefert die entschuldigte Zeile (Nachbarn beidseits). */
|
||||
/** Antritt 1 < entschuldigt < actual grade, returns the excused row (neighbors on both sides). */
|
||||
const givenThreeAttempts = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
@@ -376,7 +374,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
|
||||
};
|
||||
|
||||
it("lehnt eine Notenänderung nach einem späteren Antritt ab", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true);
|
||||
|
||||
const student = studentFor(1);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Der Schreibpfad (savePruefungFuerStudent), den saveStudentPruefung, createPruefungen und
|
||||
* savePruefungenBulk teilen. Die Validatoren decken die anderen Specs ab.
|
||||
* The write path (savePruefungFuerStudent) shared by saveStudentPruefung, createPruefungen, and
|
||||
* savePruefungenBulk. The validators cover the other specs.
|
||||
*
|
||||
* Invariante: eine Aktion schreibt genau eine Prüfung. Ausnahme: neben einer LV-Note ohne Prüfungszeile
|
||||
* schreibt der erste neue Termin zuerst Antritt 1.
|
||||
* Invariant: An action saves exactly one exam. Exception: In addition to a course grade without an exam row,
|
||||
* the first new date first saves “Attendance 1.”
|
||||
*/
|
||||
|
||||
import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { requireKonfiguration, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineDate,
|
||||
@@ -92,19 +92,19 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
const verlauf = verlaufOfStudent(data, student.uid);
|
||||
expect(verlauf.antrittCount, "both attempts counted").to.eq(2);
|
||||
expect(verlauf.maxAntritte, "the cap comes from the server").to.eq(ctx.maxAntritte);
|
||||
// der letzte Antritt ist kommissionell; darf das Tool ihn nicht anlegen, ist hier Schluss
|
||||
const anlegbar =
|
||||
// The last entry is kommissionell. if the tool cannot create it, the process ends here
|
||||
const creatable =
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false
|
||||
? ctx.maxAntritte
|
||||
: ctx.maxAntritte - 1;
|
||||
expect(verlauf.canAdd, `canAdd with 2 of ${anlegbar} possible`).to.eq(2 < anlegbar);
|
||||
expect(verlauf.canAdd, `canAdd with 2 of ${creatable} possible`).to.eq(2 < creatable);
|
||||
});
|
||||
});
|
||||
|
||||
it("hängt je Anlage eine neue Zeile an, statt die vorherige zu überschreiben", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
// nur eine explizite pruefung_id aktualisiert, ein Add fügt immer ein
|
||||
// Only an explicit “pruefung_id” is updating, an “Add” always inserts one
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
@@ -138,10 +138,10 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
let excusedId;
|
||||
seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, typ: "Termin2" })
|
||||
seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, type: "Termin2" })
|
||||
.then((seeded) => {
|
||||
excusedId = seeded.pruefungId;
|
||||
return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, typ: "Termin2" });
|
||||
return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, type: "Termin2" });
|
||||
})
|
||||
// note stays entschuldigt: a later pruefung exists, so only the datum may move
|
||||
.then(() =>
|
||||
@@ -182,8 +182,8 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Eine LV-Note ohne Prüfungszeile ist Antritt 1: aus einer Übernahme ohne Erstantritt oder aus der Zeit
|
||||
// vor dem Werkzeug. Ohne die Zeile fiele sie aus der Zählung, und der Studierende bekäme einen Antritt zu viel.
|
||||
// A course grade without an exam line is considered “Antritt 1”: either from a Übernahme without an initial attendance or from the period
|
||||
// before the tool was implemented. Without that line, it would be omitted from the count, and the student would be credited with one too many attendances.
|
||||
it("schreibt Antritt 1 aus einer LV-Note ohne Prüfungszeile nach", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
@@ -207,9 +207,9 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regel: Die LV-Note ist die Note des letzten Termins, der einen Antritt verbraucht. Ohne einen
|
||||
// solchen Termin bleibt ein impliziter Erstantritt, sonst gilt "Noch nicht eingetragen".
|
||||
// Die LV-Note ist nie 'entschuldigt'.
|
||||
// Rule: The course grade is the grade from the last session that counts as an attendance. Without such
|
||||
// a session, an implicit first attendance is assumed; otherwise, the status is “Noch nicht eingetragen”
|
||||
// The course grade is never marked as “entschuldigt”
|
||||
describe("LV-Note nach einem Termin", () => {
|
||||
const g2 = () =>
|
||||
ctx.notes.positiv ??
|
||||
@@ -222,9 +222,9 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
return row;
|
||||
});
|
||||
|
||||
const expectAntritte = (student, anzahl) =>
|
||||
const expectAntritte = (student, count) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(verlaufOfStudent(data, student.uid).antrittCount, "antrittCount").to.eq(anzahl);
|
||||
expect(verlaufOfStudent(data, student.uid).antrittCount, "antrittCount").to.eq(count);
|
||||
});
|
||||
|
||||
it("behält die LV-Note bei einer Datumskorrektur an Termin 1", function () {
|
||||
@@ -267,7 +267,7 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
});
|
||||
|
||||
// Kein requireWiederholung: der Test braucht nur Antritt 1. Mit zwei Antritten ohne
|
||||
// kommissionelle Anlage scheitert er, siehe docs/benotungstool-status.md, Abschnitt 9.
|
||||
// kommissionelle Anlage scheitert er
|
||||
it("setzt 'Noch nicht eingetragen', wenn der erste Termin entschuldigt ist", () => {
|
||||
const student = studentFor(2);
|
||||
|
||||
@@ -291,10 +291,10 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const zaehlend = attemptsOfStudent(data, student.uid).find(
|
||||
const counting = attemptsOfStudent(data, student.uid).find(
|
||||
(p) => String(p.note) === String(ctx.notes.negativ),
|
||||
);
|
||||
expect(zaehlend.antritt_nr, "der Studierende hat keinen Antritt verloren").to.eq(1);
|
||||
expect(counting.antritt_nr, "der Studierende hat keinen Antritt verloren").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
.then((response) => expectNotenSuccess(response, "Termin 1 wird entschuldigt"));
|
||||
|
||||
expectLvNote(student, ctx.notes.nochNichtEingetragen, "die LV-Note ist nie entschuldigt");
|
||||
// die alte LV-Note zählt nicht als impliziter Erstantritt
|
||||
// The old course grade does not count as an implicit first enrollment
|
||||
expectAntritte(student, 0);
|
||||
});
|
||||
|
||||
@@ -387,11 +387,11 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
describe("Freigabe nach einem neuen Termin", () => {
|
||||
beforeEach(function () {
|
||||
// eine endgültige Freigabe verbietet den neuen Termin
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_FINAL", false);
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
const neuerTerminNachFreigabe = (student) => {
|
||||
const newTerminAfterFreigabe = (student) => {
|
||||
givenBaseline(ctx, student, { freigegeben: true });
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenSuccess(response, "Termin nach der Freigabe"),
|
||||
@@ -400,18 +400,18 @@ describe("Noten API - Prüfungstermin (Schreibpfad)", () => {
|
||||
};
|
||||
|
||||
it("hebt die Freigabe mit einem neuen Termin auf", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", true);
|
||||
|
||||
neuerTerminNachFreigabe(studentFor(5)).then((row) => {
|
||||
newTerminAfterFreigabe(studentFor(5)).then((row) => {
|
||||
expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum")
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("behält die Freigabe bei einem neuen Termin", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNG_HEBT_FREIGABE_AUF", false);
|
||||
|
||||
neuerTerminNachFreigabe(studentFor(5)).then((row) => {
|
||||
newTerminAfterFreigabe(studentFor(5)).then((row) => {
|
||||
expect(new Date(row.benotungsdatum) > new Date(row.freigabedatum), "benotungsdatum nach freigabedatum")
|
||||
.to.be.false;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The role matrix: which permission may do which action.
|
||||
*
|
||||
* Each account asks getCisConfig for its actions. Each action has two tests: one runs when the matrix
|
||||
* allows the action, the other when the matrix withholds it. The profile "rollen" withholds the release
|
||||
* and the kommissionelle Prüfung from the Assistenz, otherwise the second test never runs.
|
||||
*/
|
||||
|
||||
import { expectNotenError, expectNotenSuccess, messageMatchesPhrase } from "../../../../support/helpers/notenErrors";
|
||||
import { requireKommissionellerAntritt, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import { attemptDate, loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
|
||||
|
||||
const accounts = () => [
|
||||
{
|
||||
label: "Lektor",
|
||||
user: Cypress.env("NOTEN_TEACHER_USER"),
|
||||
pass: Cypress.env("NOTEN_TEACHER_PASSWORD"),
|
||||
},
|
||||
{
|
||||
label: "Assistenz",
|
||||
user: Cypress.env("NOTEN_ASSISTENZ_USER"),
|
||||
pass: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
|
||||
},
|
||||
];
|
||||
|
||||
// the first statement of each endpoint checks its action; kommpruef is checked at the kommissionell attempt
|
||||
const ENDPOINT = {
|
||||
vorschlag: "saveNotenvorschlag",
|
||||
pruefung: "saveStudentPruefung",
|
||||
freigabe: "saveStudentenNoten",
|
||||
import: "saveNotenvorschlagBulk",
|
||||
};
|
||||
|
||||
/** A call that only reaches the action check. The empty body ends in missingParameters otherwise. */
|
||||
const callAction = (auth, action, ctx) =>
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${NOTEN_API}/${ENDPOINT[action]}`,
|
||||
body: { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz },
|
||||
auth,
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
const errorMessages = (response) => ((response.body && response.body.errors) || []).map((e) => e.message);
|
||||
|
||||
describe("Noten API - die Rollenmatrix", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
});
|
||||
});
|
||||
|
||||
accounts().forEach(({ label, user, pass }) => {
|
||||
describe(label, () => {
|
||||
const auth = { username: user, password: pass };
|
||||
let allowed = [];
|
||||
|
||||
before(function () {
|
||||
skipIf(this, !user || !pass, `Übersprungen: kein Konto für "${label}" in tests/cypress/.env.`);
|
||||
|
||||
cy.clearAllCookies();
|
||||
cy.request({ method: "GET", url: `${NOTEN_API}/getCisConfig`, auth, failOnStatusCode: false }).then(
|
||||
(response) => {
|
||||
// every account of the suite holds a permission of the matrix; seeder group benotungstool_berechtigungen creates it
|
||||
expect(response.status, `getCisConfig als ${label}`).to.eq(200);
|
||||
allowed = response.body.data.CIS_GESAMTNOTE_AKTIONEN || [];
|
||||
cy.log(`${label} darf: ${JSON.stringify(allowed)}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// cy.request sends the cookie of the suite user otherwise, and the server prefers the cookie
|
||||
beforeEach(() => cy.clearAllCookies());
|
||||
|
||||
Object.keys(ENDPOINT).forEach((action) => {
|
||||
it(`führt "${action}" aus, wenn die Matrix es erlaubt`, function () {
|
||||
skipIf(this, !allowed.includes(action), `Übersprungen: die Matrix entzieht ${label} "${action}".`);
|
||||
|
||||
callAction(auth, action, ctx).then((res) => {
|
||||
expect(
|
||||
errorMessages(res).some((m) => messageMatchesPhrase(m, "aktionNichtErlaubt")),
|
||||
`${label} darf "${action}": ${JSON.stringify(errorMessages(res))}`,
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it(`lehnt "${action}" ab, wenn die Matrix es entzieht`, function () {
|
||||
skipIf(this, allowed.includes(action), `Übersprungen: die Matrix erlaubt ${label} "${action}".`);
|
||||
|
||||
callAction(auth, action, ctx).then((res) => expectNotenError(res, "aktionNichtErlaubt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("der kommissionelle Antritt", () => {
|
||||
const student = () => ctx.students[6];
|
||||
|
||||
beforeEach(function () {
|
||||
requireKommissionellerAntritt(this, ctx);
|
||||
requireDbReset();
|
||||
});
|
||||
|
||||
/** The suite user builds the chain up to the kommissionell attempt. Returns its date. */
|
||||
const bringToBeforeKommission = () => {
|
||||
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT;
|
||||
|
||||
givenBaseline(ctx, student());
|
||||
for (let nr = 2; nr < fromAntritt; nr += 1) {
|
||||
addPruefung(ctx, student(), { note: ctx.notes.negativ, datum: attemptDate(ctx, nr - 1) }).then(
|
||||
(response) => expectNotenSuccess(response, `Antritt ${nr}`),
|
||||
);
|
||||
}
|
||||
return attemptDate(ctx, fromAntritt - 1);
|
||||
};
|
||||
|
||||
const createKommissionell = (datum) => {
|
||||
const s = student();
|
||||
// the chain requests can leave a session cookie of the suite user
|
||||
cy.clearAllCookies();
|
||||
return cy.request({
|
||||
method: "POST",
|
||||
url: `${NOTEN_API}/saveStudentPruefung`,
|
||||
body: {
|
||||
student_uid: s.uid,
|
||||
note: ctx.notes.negativ,
|
||||
punkte: null,
|
||||
datum,
|
||||
lva_id: ctx.lvId,
|
||||
lehreinheit_id: s.lehreinheit_id,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
pruefung_id: null,
|
||||
},
|
||||
auth,
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
};
|
||||
|
||||
it("legt ihn an, wenn die Matrix es erlaubt", function () {
|
||||
skipIf(
|
||||
this,
|
||||
!allowed.includes("kommpruef"),
|
||||
`Übersprungen: die Matrix entzieht ${label} "kommpruef".`,
|
||||
);
|
||||
|
||||
createKommissionell(bringToBeforeKommission()).then((response) => {
|
||||
const [saved] = expectNotenSuccess(response, `kommissioneller Antritt als ${label}`);
|
||||
expect(saved.pruefungstyp_kurzbz, "der Antritt ist kommissionell").to.eq(
|
||||
ctx.cisConfig.PRUEFUNG_TYP_KOMMISSIONELL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt ihn ab, wenn die Matrix es entzieht", function () {
|
||||
skipIf(
|
||||
this,
|
||||
allowed.includes("kommpruef"),
|
||||
`Übersprungen: die Matrix erlaubt ${label} "kommpruef".`,
|
||||
);
|
||||
|
||||
createKommissionell(bringToBeforeKommission()).then((response) =>
|
||||
expectNotenError(response, "kommPruefNichtErlaubt"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Scope of write paths: An exam belongs to the student and the course associated with the request.
|
||||
*
|
||||
* An edit affects only one exam for this student in this course. The server selects the
|
||||
* course, and a non-participant does not receive a grade. Both rejections report
|
||||
* c4pruefungNichtGespeichert. A genuine write error reports the same message, which is why every
|
||||
* test also checks the database.
|
||||
*
|
||||
* The seeder group `benotungstool_fixture_erweitert` creates a second course with two instructors.
|
||||
*/
|
||||
|
||||
import { notenApi, notenAuth, pruefungenOf } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import { requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import { assistenzAuth, requireAssistenz } from "../../../../support/helpers/notenAssistenz";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
readLvGesamtnoteViaDb,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
shiftDate,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import { attemptsOfStudent, editPruefung, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
|
||||
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
|
||||
|
||||
const dayOf = (value) => String(value).slice(0, 10);
|
||||
|
||||
describe("Noten API - Scope der Schreibpfade", () => {
|
||||
let ctx;
|
||||
let a;
|
||||
let b;
|
||||
|
||||
// null: die Instanz kann den Fall nicht bauen, der Test wird übersprungen
|
||||
let outsider = null;
|
||||
let foreign = null;
|
||||
let otherLe = null;
|
||||
let typeWithoutAntritt = null;
|
||||
|
||||
const lvScope = () => ({ lvId: ctx.lvId, semKurzbz: ctx.semKurzbz });
|
||||
|
||||
/** A student with exactly one course unit and another course unit from the same course. */
|
||||
const findOtherLe = (lehreinheiten, i = 0) => {
|
||||
if (lehreinheiten.length < 2 || i >= ctx.students.length) return cy.wrap(null, { log: false });
|
||||
|
||||
const student = ctx.students[i];
|
||||
return cy.task("noten:db:lehreinheitenOfStudent", { ...lvScope(), studentUid: student.uid }).then((own) => {
|
||||
if (own.length !== 1) return findOtherLe(lehreinheiten, i + 1);
|
||||
const other = lehreinheiten.find((le) => String(le) !== String(own[0]));
|
||||
return { student, ownLe: own[0], otherLe: other };
|
||||
});
|
||||
};
|
||||
|
||||
/** The first type from PRUEFUNG_TYPEN_OHNE_ANTRITT that tbl_pruefungstyp contains. */
|
||||
const findTypeWithoutAntritt = (types, i = 0) => {
|
||||
if (i >= types.length) return cy.wrap(null, { log: false });
|
||||
|
||||
return cy
|
||||
.task("noten:db:pruefungstypExists", { type: types[i] })
|
||||
.then((found) => (found ? types[i] : findTypeWithoutAntritt(types, i + 1)));
|
||||
};
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
[a, b] = ctx.students;
|
||||
|
||||
cy.task("noten:db:nonParticipant", lvScope()).then((uid) => {
|
||||
outsider = uid;
|
||||
});
|
||||
cy.task("noten:db:foreignLehreinheit", lvScope()).then((row) => {
|
||||
foreign = row;
|
||||
});
|
||||
findTypeWithoutAntritt(ctx.cisConfig.PRUEFUNG_TYPEN_OHNE_ANTRITT || []).then((type) => {
|
||||
typeWithoutAntritt = type;
|
||||
});
|
||||
cy.task("noten:db:lehreinheitenOfLv", lvScope())
|
||||
.then((lehreinheiten) => findOtherLe(lehreinheiten))
|
||||
.then((found) => {
|
||||
otherLe = found;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ein Edit trifft nur eine eigene Prüfung", () => {
|
||||
/** Die Prüfung von B, die der Request für A bearbeiten will. */
|
||||
const seedForeignRow = () =>
|
||||
seedPruefung(ctx, b, { note: ctx.notes.negativ, datum: baselineDate(ctx), type: "Termin1" }).then(
|
||||
(seeded) => seeded.pruefungId,
|
||||
);
|
||||
|
||||
const expectForeignRowUnchanged = (foreignId) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const storedPruefung = pruefungenOf(data, b.uid).find(
|
||||
(p) => String(p.pruefung_id) === String(foreignId),
|
||||
);
|
||||
expect(storedPruefung, "die Prüfung von B existiert weiter").to.exist;
|
||||
expect(String(storedPruefung.note), "die Prüfung von B behält ihre Note").to.eq(
|
||||
String(ctx.notes.negativ),
|
||||
);
|
||||
expect(dayOf(storedPruefung.datum), "die Prüfung von B behält ihr Datum").to.eq(baselineDate(ctx));
|
||||
});
|
||||
|
||||
it("lehnt die Prüfung eines anderen Studierenden ab", () => {
|
||||
const otherNote = ctx.gradeNotes.find((n) => String(n) !== String(ctx.notes.negativ));
|
||||
let foreignId;
|
||||
let lvNoteBefore;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a);
|
||||
seedBaseline(ctx, b, { erstantritt: false });
|
||||
seedForeignRow().then((id) => {
|
||||
foreignId = id;
|
||||
});
|
||||
readLvGesamtnoteViaDb(ctx, a.uid).then((row) => {
|
||||
lvNoteBefore = row;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, a, {
|
||||
pruefungId: foreignId,
|
||||
note: otherNote,
|
||||
datum: shiftDate(baselineDate(ctx), 3),
|
||||
}),
|
||||
).then((response) => expectNotenError(response, "c4pruefungNichtGespeichert"));
|
||||
|
||||
cy.then(() => expectForeignRowUnchanged(foreignId));
|
||||
readLvGesamtnoteViaDb(ctx, a.uid).then((row) => {
|
||||
expect(String(row.note), "die LV-Note von A bleibt").to.eq(String(lvNoteBefore.note));
|
||||
expect(String(row.benotungsdatum), "das Benotungsdatum von A bleibt").to.eq(
|
||||
String(lvNoteBefore.benotungsdatum),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt eine fremde Prüfung für einen Studierenden ohne Prüfung ab", () => {
|
||||
let foreignId;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedForeignRow().then((id) => {
|
||||
foreignId = id;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, a, {
|
||||
pruefungId: foreignId,
|
||||
note: ctx.gradeNotes[0],
|
||||
datum: shiftDate(baselineDate(ctx), 3),
|
||||
}),
|
||||
).then((response) => expectNotenError(response, "c4pruefungNichtGespeichert"));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, a.uid).then((row) => {
|
||||
expect(row, "A bekommt keine LV-Note").to.be.null;
|
||||
});
|
||||
cy.then(() => expectForeignRowUnchanged(foreignId));
|
||||
});
|
||||
|
||||
// Der Server sperrt jeden Typ aus PRUEFUNG_TYPEN_OHNE_ANTRITT für Edits.
|
||||
// Aktuell: zusKommPruef, den die Studierendenverwaltung einträgt.
|
||||
it("lehnt eine Prüfung der Studierendenverwaltung ab", function () {
|
||||
skipIf(
|
||||
this,
|
||||
!typeWithoutAntritt,
|
||||
"Übersprungen: kein Typ aus PRUEFUNG_TYPEN_OHNE_ANTRITT in tbl_pruefungstyp.",
|
||||
);
|
||||
|
||||
const datum = attemptDate(ctx, 1);
|
||||
let pruefungId;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a);
|
||||
seedPruefung(ctx, a, { note: ctx.notes.negativ, datum, type: typeWithoutAntritt }).then((seeded) => {
|
||||
pruefungId = seeded.pruefungId;
|
||||
});
|
||||
|
||||
cy.then(() =>
|
||||
editPruefung(ctx, a, { pruefungId, note: ctx.notes.negativ, datum: shiftDate(datum, 2) }),
|
||||
).then((response) => expectNotenError(response, "c4pruefungNichtGespeichert"));
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const storedPruefung = pruefungenOf(data, a.uid).find(
|
||||
(p) => String(p.pruefung_id) === String(pruefungId),
|
||||
);
|
||||
expect(storedPruefung, `die Prüfung vom Typ ${typeWithoutAntritt} existiert weiter`).to.exist;
|
||||
expect(dayOf(storedPruefung.datum), "sie behält ihr Datum").to.eq(datum);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("die Lehreinheit einer Prüfung", () => {
|
||||
const terminFor = (uid, lehreinheitId) =>
|
||||
notenApi.saveStudentPruefung({
|
||||
student_uid: uid,
|
||||
note: ctx.notes.negativ,
|
||||
punkte: null,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lva_id: ctx.lvId,
|
||||
lehreinheit_id: lehreinheitId,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
pruefung_id: null,
|
||||
});
|
||||
|
||||
// The server replaces the teaching unit without displaying an error message.
|
||||
it("ersetzt eine andere Lehreinheit derselben LV", function () {
|
||||
skipIf(this, !otherLe, "Übersprungen: die LV hat keine zweite Lehreinheit.");
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const { student, ownLe } = otherLe;
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student);
|
||||
|
||||
terminFor(student.uid, otherLe.otherLe).then((response) => {
|
||||
const [saved] = expectNotenSuccess(response, "Termin mit einer anderen Lehreinheit");
|
||||
expect(String(saved.lehreinheit_id), "die Lehreinheit des Studierenden").to.eq(String(ownLe));
|
||||
});
|
||||
});
|
||||
|
||||
it("ersetzt die Lehreinheit einer fremden LV", function () {
|
||||
skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV.");
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a);
|
||||
|
||||
terminFor(a.uid, foreign.lehreinheit_id)
|
||||
.then((response) => {
|
||||
// resetNotenState does not reach a line in the external LV
|
||||
const saved = response.body?.data?.[0];
|
||||
if (saved && String(saved.lehreinheit_id) === String(foreign.lehreinheit_id)) {
|
||||
cy.task("noten:db:deletePruefung", { pruefungId: saved.pruefung_id, studentUids: [a.uid] });
|
||||
}
|
||||
return cy.wrap(response, { log: false });
|
||||
})
|
||||
.then((response) => {
|
||||
const [saved] = expectNotenSuccess(response, "Termin mit einer fremden Lehreinheit");
|
||||
return cy
|
||||
.task("noten:db:lehreinheitenOfStudent", { ...lvScope(), studentUid: a.uid })
|
||||
.then((own) => {
|
||||
expect(own.map(String), "eine Lehreinheit des Studierenden").to.include(
|
||||
String(saved.lehreinheit_id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(attemptsOfStudent(data, a.uid), "Antritt 1 und der neue Termin").to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nicht-Teilnehmer", () => {
|
||||
beforeEach(function () {
|
||||
skipIf(this, !outsider, "Übersprungen: kein aktiver Studierender ausserhalb der LV.");
|
||||
resetNotenState(ctx, [outsider]);
|
||||
});
|
||||
|
||||
// The reset is the check: It clears what the request wrote and counts it.
|
||||
const expectNothingWritten = () =>
|
||||
resetNotenState(ctx, [outsider]).then((deleted) => {
|
||||
expect(deleted.deletedLvGesamtnoten, `LV-Noten von ${outsider}`).to.eq(0);
|
||||
expect(deleted.deletedPruefungen, `Prüfungen von ${outsider}`).to.eq(0);
|
||||
});
|
||||
|
||||
it("saveStudentPruefung legt nichts an", () => {
|
||||
terminFor(outsider, a.lehreinheit_id).then((response) =>
|
||||
expectNotenError(response, "c4pruefungNichtGespeichert"),
|
||||
);
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("createPruefungen lehnt nur die Zeile des Nicht-Teilnehmers ab", () => {
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.createPruefungen(
|
||||
[
|
||||
{ uid: a.uid, lehreinheit_id: a.lehreinheit_id },
|
||||
{ uid: outsider, lehreinheit_id: a.lehreinheit_id },
|
||||
],
|
||||
attemptDate(ctx, 1),
|
||||
ctx.lvId,
|
||||
ctx.semKurzbz,
|
||||
)
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "createPruefungen");
|
||||
expectBulkRowError(data, outsider, "c4pruefungNichtGespeichert");
|
||||
expect(data[a.uid].savedPruefung, `Prüfung für ${a.uid}`).to.exist;
|
||||
});
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("savePruefungenBulk lehnt nur die Zeile des Nicht-Teilnehmers ab", function () {
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
const bulkRow = (uid) => ({
|
||||
uid,
|
||||
note: ctx.notes.negativ,
|
||||
punkte: null,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lehreinheit_id: a.lehreinheit_id,
|
||||
});
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [bulkRow(a.uid), bulkRow(outsider)])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "savePruefungenBulk");
|
||||
expectBulkRowError(data, outsider, "c4pruefungNichtGespeichert");
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
});
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("saveNotenvorschlag schreibt keine LV-Note", () => {
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, outsider, ctx.notes.negativ)
|
||||
.then((response) => expectNotenError(response, "c4pruefungNichtGespeichert"));
|
||||
expectNothingWritten();
|
||||
});
|
||||
|
||||
it("saveNotenvorschlagBulk lehnt nur die Zeile des Nicht-Teilnehmers ab", function () {
|
||||
requireNotenMode(this, ctx);
|
||||
|
||||
resetNotenState(ctx);
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
{ uid: a.uid, note: ctx.gradeNotes[0], punkte: null },
|
||||
{ uid: outsider, note: ctx.gradeNotes[0], punkte: null },
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "saveNotenvorschlagBulk");
|
||||
expectBulkRowError(data, outsider, "c4pruefungNichtGespeichert");
|
||||
expectBulkRowAccepted(data, a.uid);
|
||||
});
|
||||
expectNothingWritten();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLehrendeFuerLehreinheit", () => {
|
||||
it("nennt die Lehrenden einer Lehreinheit der LV", () => {
|
||||
notenApi.getLehrendeFuerLehreinheit(a.lehreinheit_id, ctx.lvId, ctx.semKurzbz).then((response) => {
|
||||
expect(expectNotenSuccess(response, "eigene Lehreinheit"), "Lehrende").to.be.an("array");
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt die Lehreinheit einer fremden LV ab", function () {
|
||||
skipIf(this, !foreign, "Übersprungen: das Semester hat keine Lehreinheit einer anderen LV.");
|
||||
|
||||
notenApi
|
||||
.getLehrendeFuerLehreinheit(foreign.lehreinheit_id, ctx.lvId, ctx.semKurzbz)
|
||||
.then((response) => expectNotenError(response, "wrongParameters"));
|
||||
});
|
||||
|
||||
it("lehnt eine nicht numerische Lehreinheit ab", () => {
|
||||
notenApi
|
||||
.getLehrendeFuerLehreinheit("abc", ctx.lvId, ctx.semKurzbz)
|
||||
.then((response) => expectNotenError(response, "missingParameters"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The person listed as the grader for the exam and course grade: a valid selection, otherwise, the caller if they
|
||||
// teach the lehreinheit, otherwise, the first instructor of the lehreinheit based on the UID.
|
||||
describe("die benotende Person", () => {
|
||||
// ein Studierender, dessen Lehreinheit mehrere Lehrende hat; der Suite-Benutzer steht nicht an erster Stelle
|
||||
let target = null;
|
||||
let suiteUid = null;
|
||||
|
||||
before(() => {
|
||||
const search = (i) =>
|
||||
i >= ctx.students.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: cy
|
||||
.task("noten:db:lehrendeOfLehreinheit", { lehreinheitId: ctx.students[i].lehreinheit_id })
|
||||
.then((lehrende) =>
|
||||
lehrende.length > 1 && lehrende.includes(suiteUid) && lehrende[0] !== suiteUid
|
||||
? { student: ctx.students[i], lehrende }
|
||||
: search(i + 1),
|
||||
);
|
||||
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID",
|
||||
auth: notenAuth(),
|
||||
})
|
||||
.then((response) => {
|
||||
suiteUid = response.body.data.uid;
|
||||
return search(0);
|
||||
})
|
||||
.then((found) => {
|
||||
target = found;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
expect(
|
||||
target,
|
||||
"eine Lehreinheit mit mehreren Lehrenden, darunter der Suite-Benutzer (Seeder-Gruppe benotungstool_fixture_erweitert)",
|
||||
).to.not.be.null;
|
||||
requireDbReset();
|
||||
resetNotenState(ctx);
|
||||
});
|
||||
|
||||
const terminBody = (mitarbeiter_uid) => ({
|
||||
student_uid: target.student.uid,
|
||||
note: ctx.notes.negativ,
|
||||
punkte: null,
|
||||
datum: attemptDate(ctx, 1),
|
||||
lva_id: ctx.lvId,
|
||||
lehreinheit_id: target.student.lehreinheit_id,
|
||||
sem_kurzbz: ctx.semKurzbz,
|
||||
pruefung_id: null,
|
||||
mitarbeiter_uid,
|
||||
});
|
||||
|
||||
const grader = (response) => {
|
||||
const [saved] = expectNotenSuccess(response, "Termin");
|
||||
return cy.task("noten:db:pruefungMitarbeiter", {
|
||||
pruefungId: saved.pruefung_id,
|
||||
studentUid: target.student.uid,
|
||||
});
|
||||
};
|
||||
|
||||
it("trägt den Aufrufer ein, wenn er die Lehreinheit unterrichtet", () => {
|
||||
notenApi
|
||||
.saveStudentPruefung(terminBody())
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(suiteUid));
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, target.student.uid).then((row) => {
|
||||
expect(row.mitarbeiter_uid, "benotende Person der LV-Note").to.eq(suiteUid);
|
||||
});
|
||||
});
|
||||
|
||||
it("trägt die gewählte Lehrperson ein", () => {
|
||||
const chosen = target.lehrende.find((uid) => uid !== suiteUid);
|
||||
|
||||
notenApi
|
||||
.saveStudentPruefung(terminBody(chosen))
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(chosen));
|
||||
});
|
||||
|
||||
it("übergeht eine Auswahl ausserhalb der Lehreinheit", () => {
|
||||
notenApi
|
||||
.saveStudentPruefung(terminBody("keine-lehrperson"))
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(suiteUid));
|
||||
});
|
||||
|
||||
it("trägt ohne eigene Lehre die erste Lehrperson der Lehreinheit ein", function () {
|
||||
requireAssistenz(this);
|
||||
|
||||
// otherwise, the request will be processed using the Suite user's cookie
|
||||
cy.clearAllCookies();
|
||||
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${NOTEN_API}/saveStudentPruefung`,
|
||||
body: terminBody(),
|
||||
auth: assistenzAuth(),
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.then(grader)
|
||||
.then((uid) => expect(uid, "benotende Person der Prüfung").to.eq(target.lehrende[0]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Configurable exam date guards.
|
||||
*
|
||||
* No rule value is hardcoded. Each switch has one test per branch. A test skips itself when the other
|
||||
* branch is configured, and a profile in tests/cypress/profiles/noten.js runs it.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
import {
|
||||
expectBulkRowAccepted,
|
||||
expectBulkRowError,
|
||||
expectNotenError,
|
||||
expectNotenSuccess,
|
||||
} from "../../../../support/helpers/notenErrors";
|
||||
import { requireConfig, requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
shiftDate,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
import {
|
||||
addPruefung,
|
||||
attemptsOfStudent,
|
||||
editPruefung,
|
||||
givenBaseline,
|
||||
readStateViaApi,
|
||||
} from "../../../../support/helpers/notenScenario";
|
||||
|
||||
describe("Noten API - konfigurierbare Terminregeln", () => {
|
||||
let ctx;
|
||||
|
||||
before(() => {
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
cy.log(`TERMIN_GLEICHER_TAG = ${ctx.cisConfig.CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG}`);
|
||||
cy.log(
|
||||
`NOTE_SPERRE_BEI_SPAETEREM_TERMIN = ${ctx.cisConfig.CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN}`,
|
||||
);
|
||||
cy.log(`DATUM_ZUKUNFT = ${ctx.cisConfig.CIS_GESAMTNOTE_DATUM_ZUKUNFT}`);
|
||||
cy.log(`ANTRITT_MIN_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE}`);
|
||||
cy.log(`ANTRITT_MAX_ABSTAND_TAGE = ${ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE}`);
|
||||
});
|
||||
});
|
||||
|
||||
const studentFor = (index) => {
|
||||
const student = ctx.students[index];
|
||||
expect(student, `student at index ${index}`).to.exist;
|
||||
return student;
|
||||
};
|
||||
|
||||
beforeEach(() => requireDbReset());
|
||||
|
||||
// The rules compare Y-m-d strings. A different format sorts the occurrences incorrectly.
|
||||
describe("Prüfungsdatum", () => {
|
||||
const impossibleDay = () => `${baselineDate(ctx).slice(0, 4)}-02-31`;
|
||||
|
||||
[
|
||||
["einen Kalendertag, den es nicht gibt", impossibleDay],
|
||||
["das Format d.m.Y", () => attemptDate(ctx, 1).split("-").reverse().join(".")],
|
||||
["ein leeres Datum", () => ""],
|
||||
].forEach(([label, datum]) => {
|
||||
it(`lehnt ${label} ab`, () => {
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: datum() }).then((response) =>
|
||||
expectNotenError(response, "pruefungsdatumUngueltig"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(attemptsOfStudent(data, student.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("speichert ein Datum mit Uhrzeit als Kalendertag", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = studentFor(1);
|
||||
const datum = attemptDate(ctx, 1);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: `${datum} 10:00:00` }).then((response) =>
|
||||
expectNotenSuccess(response, "Datum mit Uhrzeit"),
|
||||
);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const newTermin = attemptsOfStudent(data, student.uid)[1];
|
||||
expect(String(newTermin.datum).slice(0, 10), "der Kalendertag").to.eq(datum);
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt im Import nur die Zeile mit dem ungültigen Datum ab", function () {
|
||||
requireNotenMode(this, ctx);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const valid = studentFor(0);
|
||||
const invalid = studentFor(1);
|
||||
const bulkRow = (student, datum) => ({
|
||||
uid: student.uid,
|
||||
note: ctx.notes.negativ,
|
||||
punkte: null,
|
||||
datum,
|
||||
lehreinheit_id: student.lehreinheit_id,
|
||||
});
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, valid);
|
||||
seedBaseline(ctx, invalid);
|
||||
|
||||
notenApi
|
||||
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
|
||||
bulkRow(valid, attemptDate(ctx, 1)),
|
||||
bulkRow(invalid, impossibleDay()),
|
||||
])
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "savePruefungenBulk");
|
||||
expectBulkRowAccepted(data, valid.uid);
|
||||
expectBulkRowError(data, invalid.uid, "pruefungsdatumUngueltig");
|
||||
});
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(attemptsOfStudent(data, invalid.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A second appointment on the same day: either too early or allowed as per config.
|
||||
describe("CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", () => {
|
||||
beforeEach(function () {
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
// The initial start date is baselineDate, the new date falls exactly on that day
|
||||
const terminOnBaselineDay = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: baselineDate(ctx) });
|
||||
};
|
||||
|
||||
it("lehnt einen Termin am selben Tag ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false);
|
||||
|
||||
terminOnBaselineDay(studentFor(0)).then((response) =>
|
||||
expectNotenError(response, "pruefungDatumBeforeExisting"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt einen Termin am selben Tag an, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", true);
|
||||
|
||||
terminOnBaselineDay(studentFor(0)).then((response) => expectNotenSuccess(response, "Termin am selben Tag"));
|
||||
});
|
||||
|
||||
it("lehnt einen Termin vor einem bestehenden immer ab", () => {
|
||||
const student = studentFor(1);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
// One day earlier is too early in both configurations
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[1],
|
||||
datum: shiftDate(baselineDate(ctx), -1),
|
||||
}).then((response) => expectNotenError(response, "pruefungDatumBeforeExisting"));
|
||||
});
|
||||
});
|
||||
|
||||
// Does a later appointment block the earlier one?
|
||||
describe("CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", () => {
|
||||
beforeEach(function () {
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
/** First performance and a later date. After that, the request only changes the score for the first performance. */
|
||||
const changeNoteOfAntritt1 = (student) => {
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
|
||||
.then((response) => expectNotenSuccess(response, "späterer Termin"))
|
||||
.then(() => readStateViaApi(ctx))
|
||||
.then((data) => {
|
||||
const firstAntritt = attemptsOfStudent(data, student.uid)[0];
|
||||
expect(firstAntritt, "Antritt 1").to.exist;
|
||||
|
||||
return editPruefung(ctx, student, {
|
||||
pruefungId: firstAntritt.pruefung_id,
|
||||
note: ctx.gradeNotes[1],
|
||||
datum: firstAntritt.datum,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
it("sperrt die Note eines früheren Termins", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true);
|
||||
|
||||
changeNoteOfAntritt1(studentFor(2)).then((response) => expectNotenError(response, "pruefungNoteLocked"));
|
||||
});
|
||||
|
||||
it("lässt die Note eines früheren Termins ändern, wenn der Schalter aus ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", false);
|
||||
|
||||
changeNoteOfAntritt1(studentFor(2)).then((response) =>
|
||||
expectNotenSuccess(response, "Note trotz späterem Termin"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The grading date is in the future.
|
||||
describe("CIS_GESAMTNOTE_DATUM_ZUKUNFT", () => {
|
||||
const uebernahmeWithTomorrow = (student) => {
|
||||
const tomorrow = shiftDate(new Date().toISOString().slice(0, 10), 1);
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
return notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0], null, tomorrow);
|
||||
};
|
||||
|
||||
it("lehnt ein künftiges Benotungsdatum ab", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", false);
|
||||
|
||||
uebernahmeWithTomorrow(studentFor(3)).then((response) =>
|
||||
expectNotenError(response, "benotungsdatumInZukunft"),
|
||||
);
|
||||
});
|
||||
|
||||
it("nimmt ein künftiges Benotungsdatum an, wenn der Schalter an ist", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_DATUM_ZUKUNFT", true);
|
||||
|
||||
uebernahmeWithTomorrow(studentFor(3)).then((response) =>
|
||||
expectNotenSuccess(response, "künftiges Benotungsdatum"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Die Wartefrist zwischen zwei Antritten. Ohne Wert ist die Regel aus.
|
||||
describe("CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE", () => {
|
||||
let min;
|
||||
|
||||
beforeEach(function () {
|
||||
min = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE);
|
||||
skipIf(
|
||||
this,
|
||||
!Number.isFinite(min) || min <= 0,
|
||||
"Übersprungen: CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE ist nicht gesetzt.",
|
||||
);
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
it("lehnt einen Antritt vor Ablauf der Wartefrist ab", () => {
|
||||
const student = studentFor(4);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[1],
|
||||
datum: shiftDate(baselineDate(ctx), min - 1),
|
||||
}).then((response) => expectNotenError(response, "pruefungAbstandZuKurz"));
|
||||
});
|
||||
|
||||
it("nimmt einen Antritt genau am Ende der Wartefrist an", () => {
|
||||
const student = studentFor(5);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[1],
|
||||
datum: shiftDate(baselineDate(ctx), min),
|
||||
}).then((response) => expectNotenSuccess(response, "Antritt am Ende der Wartefrist"));
|
||||
});
|
||||
});
|
||||
|
||||
// Die Höchstfrist bis zum nächsten Antritt.
|
||||
describe("CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE", () => {
|
||||
let max;
|
||||
|
||||
beforeEach(function () {
|
||||
max = Number(ctx.cisConfig.CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE);
|
||||
skipIf(
|
||||
this,
|
||||
!Number.isFinite(max) || max <= 0,
|
||||
"Übersprungen: CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE ist nicht gesetzt.",
|
||||
);
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
it("lehnt einen Antritt nach Ablauf der Höchstfrist ab", () => {
|
||||
const student = studentFor(6);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
|
||||
addPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[1],
|
||||
datum: shiftDate(baselineDate(ctx), max + 1),
|
||||
}).then((response) => expectNotenError(response, "pruefungAbstandZuLang"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,14 @@
|
||||
/**
|
||||
* Overwrite-Regeln der Notenvorschlag-Spalte.
|
||||
* Overwrite rules for the grade suggestion column.
|
||||
*
|
||||
* Drei Regeln: die Editorliste bietet nur lehre-Noten an, die Spalte ist gesperrt, sobald eine
|
||||
* Prüfung existiert, und eine Zeugnisnote mit lkt_ueberschreibbar = false sperrt sie ebenfalls.
|
||||
* Alle drei stehen in Benotungstool.js UND in Noten::validateNotenvorschlag - der direkte
|
||||
* API-Aufruf und der CSV-Import erreichen den Client nie.
|
||||
* Three rules: the editor list only offers “lehre” noten, the column is locked as soon as an
|
||||
* exam exists; and a report card grade with lkt_ueberschreibbar = false also locks it.
|
||||
* All three are defined in Benotungstool.js AND in Noten::validateNotenvorschlag the direct
|
||||
* API call and the CSV import never reach the client.
|
||||
*/
|
||||
|
||||
import { expectBulkRowError, expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import {
|
||||
requireKonfiguration,
|
||||
requireNotenModus,
|
||||
requireWiederholung,
|
||||
skipWenn,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
@@ -43,17 +38,17 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
|
||||
const studentFor = (index) => ctx.students[index % ctx.students.length];
|
||||
|
||||
it("lehnt eine Note ab, die die Auswahlliste nicht anbietet", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true);
|
||||
// auf dieser Instanz kann jede aktive Note eine Lehrenote sein
|
||||
skipWenn(this, ctx.notes.nichtLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note.");
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true);
|
||||
// On this instance, any active note can be a lehre note
|
||||
skipIf(this, ctx.notes.notLehre === null, "Übersprungen: keine Verwaltungsnote in tbl_note.");
|
||||
|
||||
const student = studentFor(0);
|
||||
|
||||
// ohne Erstantritt: sonst greift die Prüfungsregel und die Notenregel bliebe ungeprüft
|
||||
// without a first attempt: otherwise, the review rule applies and the grading rule would remain unreviewed
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.nichtLehre)
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.notes.notLehre)
|
||||
.then((response) => expectNotenError(response, "c4noteNichtInLehre"))
|
||||
.then(() => readLvGesamtnoteViaDb(ctx, student.uid))
|
||||
.then((row) => {
|
||||
@@ -86,12 +81,12 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
|
||||
|
||||
it("lehnt das Überschreiben einer gesperrten Zeugnisnote ab", function () {
|
||||
// auf dieser Instanz darf die Lehrperson jede aktive Note überschreiben
|
||||
skipWenn(this, ctx.notes.nichtUeberschreibbar === null, "Übersprungen: keine gesperrte Note in tbl_note.");
|
||||
skipIf(this, ctx.notes.notUeberschreibbar === null, "Übersprungen: keine gesperrte Note in tbl_note.");
|
||||
|
||||
const student = studentFor(4);
|
||||
|
||||
givenBaseline(ctx, student, { erstantritt: false });
|
||||
seedZeugnisnote(ctx, student.uid, ctx.notes.nichtUeberschreibbar);
|
||||
seedZeugnisnote(ctx, student.uid, ctx.notes.notUeberschreibbar);
|
||||
|
||||
notenApi
|
||||
.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[1])
|
||||
@@ -102,35 +97,35 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W1: Ein Termin schreibt dieselbe LV-Note wie die Übernahme und prüft dieselben Notenregeln.
|
||||
// Ein Termin schreibt dieselbe LV-Note wie die Übernahme und prüft dieselben Notenregeln.
|
||||
describe("Prüfungspfade", () => {
|
||||
/** Eine Verwaltungsnote, die einen Antritt verbraucht. Noten ohne Antritt bleiben erlaubt. */
|
||||
const skipOhneVerwaltungsnote = (test) => {
|
||||
requireKonfiguration(test, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true);
|
||||
const skipWithoutVerwaltungsnote = (test) => {
|
||||
requireConfig(test, ctx, "CIS_GESAMTNOTE_VORSCHLAG_NUR_LEHRENOTEN", true);
|
||||
|
||||
const ohneAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String);
|
||||
const brauchbar = ctx.notes.nichtLehre !== null && !ohneAntritt.includes(String(ctx.notes.nichtLehre));
|
||||
const withoutAntritt = (ctx.cisConfig.NOTEN_OHNE_ANTRITT || []).map(String);
|
||||
const usable = ctx.notes.notLehre !== null && !withoutAntritt.includes(String(ctx.notes.notLehre));
|
||||
|
||||
skipWenn(test, !brauchbar, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht.");
|
||||
skipIf(test, !usable, "Übersprungen: keine Verwaltungsnote, die einen Antritt verbraucht.");
|
||||
};
|
||||
|
||||
const expectNurAntritt1 = (student) =>
|
||||
const expectOnlyAntritt1 = (student) =>
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
expect(attemptsOfStudent(data, student.uid), "nur Antritt 1").to.have.length(1);
|
||||
});
|
||||
|
||||
it("lehnt einen Termin mit einer Verwaltungsnote ab", function () {
|
||||
skipOhneVerwaltungsnote(this);
|
||||
skipWithoutVerwaltungsnote(this);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = studentFor(0);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
addPruefung(ctx, student, { note: ctx.notes.nichtLehre, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
addPruefung(ctx, student, { note: ctx.notes.notLehre, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "c4noteNichtInLehre"),
|
||||
);
|
||||
|
||||
expectNurAntritt1(student);
|
||||
expectOnlyAntritt1(student);
|
||||
readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
|
||||
expect(String(row.note), "die LV-Note bleibt").to.eq(String(ctx.notes.negativ));
|
||||
});
|
||||
@@ -139,27 +134,27 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
|
||||
it("lehnt einen Termin bei gesperrter Zeugnisnote ab", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
// eine Anrechnungsnote meldet c4angerechnetKeinePruefung, deshalb schliesst der Test sie aus
|
||||
// A anrechnungs grade reports “c4angerechnetKeinePruefung,” so the test excludes it
|
||||
const anrechnung = (ctx.cisConfig.NOTEN_ANRECHNUNG || []).map(String);
|
||||
const gesperrt = ctx.notenOptions.find(
|
||||
const locked = ctx.notenOptions.find(
|
||||
(n) => n.lkt_ueberschreibbar === false && !anrechnung.includes(String(n.note)),
|
||||
);
|
||||
skipWenn(this, !gesperrt, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten.");
|
||||
skipIf(this, !locked, "Übersprungen: keine gesperrte Note ausser den Anrechnungsnoten.");
|
||||
|
||||
const student = studentFor(1);
|
||||
|
||||
givenBaseline(ctx, student);
|
||||
seedZeugnisnote(ctx, student.uid, gesperrt.note);
|
||||
seedZeugnisnote(ctx, student.uid, locked.note);
|
||||
|
||||
addPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1) }).then((response) =>
|
||||
expectNotenError(response, "c4zeugnisnoteGesperrt"),
|
||||
);
|
||||
|
||||
expectNurAntritt1(student);
|
||||
expectOnlyAntritt1(student);
|
||||
});
|
||||
|
||||
it("lehnt im Sammeldialog jede Zeile mit einer Verwaltungsnote ab", function () {
|
||||
skipOhneVerwaltungsnote(this);
|
||||
skipWithoutVerwaltungsnote(this);
|
||||
|
||||
const students = [studentFor(0), studentFor(1)];
|
||||
|
||||
@@ -171,7 +166,7 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
|
||||
attemptDate(ctx, 1),
|
||||
ctx.lvId,
|
||||
ctx.semKurzbz,
|
||||
ctx.notes.nichtLehre,
|
||||
ctx.notes.notLehre,
|
||||
)
|
||||
.then((response) => {
|
||||
const data = expectNotenSuccess(response, "createPruefungen mit einer Verwaltungsnote");
|
||||
@@ -186,13 +181,13 @@ describe("Noten API - Regeln zum Überschreiben des Notenvorschlags", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Derselbe Weg über den CSV-Import, den eine Assistenz tatsächlich fährt. Der Bulk-Endpunkt
|
||||
// antwortet 200 und meldet die abgelehnte Zeile in data[uid].
|
||||
// The same process via CSV import that an assistant actually follows. The bulk endpoint
|
||||
// returns a 200 status code and reports the rejected row in data[uid].
|
||||
describe("saveNotenvorschlagBulk", () => {
|
||||
beforeEach(function () {
|
||||
// im Punktemodus leitet der Import die Note aus den Punkten ab und verwirft eine
|
||||
// Zeile ohne Punkte, bevor eine dieser Regeln greift
|
||||
requireNotenModus(this, ctx);
|
||||
// In point mode, the import calculates the grade based on the points and discards a
|
||||
// row with no points before any of these rules take effect
|
||||
requireNotenMode(this, ctx);
|
||||
requireWiederholung(this, ctx);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireConfig, requireNotenMode, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
requireDbReset,
|
||||
resetNotenState,
|
||||
seedBaseline,
|
||||
seedPruefung,
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* The server rejects the save, and the interface displays this.
|
||||
*
|
||||
* Each test loads the table and then modifies the database. The table is therefore out of date, as if
|
||||
* an entry had been made in a second window. The client checks against the old state and sends the request;
|
||||
* only then does the server reject it. The test reads the message from the response and expects exactly that message in
|
||||
* a toast notification. It therefore does not hard-code any phrase and runs in every profile.
|
||||
*/
|
||||
context("Benotungstool UI - Ablehnung durch den Server", () => {
|
||||
let ctx;
|
||||
let bezeichnung;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
loadNotenContext().then((context) => {
|
||||
ctx = context;
|
||||
bezeichnung = (note) => page.bezeichnungOf(ctx, note);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
// im Punktemodus sind die Notenfelder gesperrt, die Note kommt aus den Punkten
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
/** An excused termin after the date entered in the test. It does not count toward the attendance limit. */
|
||||
const laterTerminInBackground = (student) =>
|
||||
seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 3), type: "Termin2" });
|
||||
|
||||
it("zeigt die Ablehnung eines Termins aus dem Zelldialog", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
laterTerminInBackground(student);
|
||||
|
||||
page.submitPruefungInCell(student.uid, "antritt_2", {
|
||||
note: bezeichnung(ctx.notes.negativ),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
|
||||
page.expectRejected("@saveStudentPruefung");
|
||||
page.expectNoPruefung(student.uid, "antritt_2");
|
||||
});
|
||||
|
||||
it("zeigt die Ablehnung einer Übernahme und behält die LV-Note", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
// A duplicate entry prevents the update; the table is unaware of this
|
||||
seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
// “Sehr Gut” first: setNotenvorschlag uses the “contains” operator; ‘Gut’ would match “Sehr Gut”
|
||||
page.setNotenvorschlag(student.uid, bezeichnung(ctx.gradeNotes[0]));
|
||||
page.submitUebernahme(student.uid);
|
||||
|
||||
page.expectRejected("@saveNotenvorschlag");
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notes.negativ));
|
||||
});
|
||||
|
||||
it("zeigt die abgelehnte Zeile der Sammelanlage und meldet keinen Erfolg", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[2];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
laterTerminInBackground(student);
|
||||
|
||||
page.submitPruefungBulk({
|
||||
uids: [student.uid],
|
||||
note: bezeichnung(ctx.notes.negativ),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
|
||||
page.expectRowRejected("@createPruefungen", student.uid);
|
||||
page.expectNoSuccess();
|
||||
page.expectNoPruefung(student.uid, "antritt_2");
|
||||
});
|
||||
|
||||
it("zeigt die abgelehnte Zeile des Prüfungsimports und meldet keinen Erfolg", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[3];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
laterTerminInBackground(student);
|
||||
|
||||
page.submitPruefungImport([
|
||||
[
|
||||
student.uid,
|
||||
page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
|
||||
ctx.notes.negativ,
|
||||
],
|
||||
]);
|
||||
|
||||
page.expectRowRejected("@savePruefungenBulk", student.uid);
|
||||
page.expectNoSuccess();
|
||||
page.expectNoPruefung(student.uid, "antritt_2");
|
||||
});
|
||||
|
||||
it("zeigt die abgelehnte Zeile des Notenimports und meldet keinen Erfolg", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const student = ctx.students[4];
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: false, erstantritt: false });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
seedPruefung(ctx, student, { note: ctx.notes.negativ, datum: attemptDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
page.submitNotenImport([[student.uid, ctx.gradeNotes[0]]]);
|
||||
|
||||
page.expectRowRejected("@saveNotenvorschlagBulk", student.uid);
|
||||
page.expectNoSuccess();
|
||||
page.expectLvNote(student.uid, bezeichnung(ctx.notes.negativ));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notenAuth } from "../../../../support/api/notenApi";
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireKonfiguration } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
baselineDate,
|
||||
loadNotenContext,
|
||||
@@ -10,15 +11,15 @@ import {
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Notenfreigabe über den Modal-Dialog mit Passwort.
|
||||
* Approve grades via the modal dialog using a password.
|
||||
*
|
||||
* Der Dialog listet genau die Zeilen, die freigegeben werden (changedNoten), und verlangt das
|
||||
* LDAP-Passwort. Danach muss die Statusspalte ohne Reload von changed auf ok springen.
|
||||
* The dialog lists exactly which rows are being released (changedNoten) and prompts for the
|
||||
* LDAP password. Afterward, the status column must change from “changed” to “ok” without a reload.
|
||||
*
|
||||
* Eine erfolgreiche Freigabe verschickt die Freigabemail. Die Entwicklungsinstanzen stellen sie in ein
|
||||
* Debug-Postfach zu.
|
||||
* A successful release triggers the release email. The development instances deliver it to a
|
||||
* debug mailbox.
|
||||
*/
|
||||
const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || Cypress.env("adminpassword");
|
||||
const freigabePassword = () => Cypress.env("NOTEN_FREIGABE_PASSWORD") || notenAuth().password;
|
||||
|
||||
context("Benotungstool UI - Notenfreigabe", () => {
|
||||
let ctx;
|
||||
@@ -47,8 +48,8 @@ context("Benotungstool UI - Notenfreigabe", () => {
|
||||
});
|
||||
|
||||
it("lehnt ein falsches Passwort ab und lässt den Status unverändert", function () {
|
||||
// ohne Passwortpflicht gibt der Dialog frei
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
|
||||
// If no password is required, the dialog box allows access
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_FREIGABE_PASSWORT", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -58,11 +59,11 @@ context("Benotungstool UI - Notenfreigabe", () => {
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.openFreigabeModal();
|
||||
|
||||
page.typeFreigabePasswort("definitely-not-the-password");
|
||||
page.typeFreigabePassword("definitely-not-the-password");
|
||||
page.submitFreigabe();
|
||||
|
||||
// die Oberfläche zeigt die Ablehnung, der Status bleibt
|
||||
page.expectAbgelehnt("@saveStudentenNoten");
|
||||
// The interface displays the rejection, but the status remains the same
|
||||
page.expectRejected("@saveStudentenNoten");
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student.uid).then((rowData) => {
|
||||
@@ -89,7 +90,7 @@ context("Benotungstool UI - Notenfreigabe", () => {
|
||||
});
|
||||
|
||||
it("legt mit der Freigabe den ersten Antritt an", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
@@ -100,12 +101,12 @@ context("Benotungstool UI - Notenfreigabe", () => {
|
||||
page.openFreigabeModal();
|
||||
page.freigeben(freigabePassword());
|
||||
|
||||
// upsertErstantritt schreibt die Prüfungszeile; sichtbar wird sie beim nächsten Laden
|
||||
// upsertErstantritt writes the audit entry; it becomes visible the next time the page is loaded
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
|
||||
// der Antritt übernimmt das Benotungsdatum der LV-Note, kein implizites Datum
|
||||
// The start date uses the grading date of the course grade; no implicit date is used
|
||||
page.getCell(student.uid, "antritt_1").should("contain.text", page.toDDMMYYYY(baselineDate(ctx)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import {
|
||||
requireKonfiguration,
|
||||
requireNotenModus,
|
||||
requireWiederholung,
|
||||
skipWenn,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireNotenMode, requireWiederholung, skipIf } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
@@ -14,13 +9,13 @@ import {
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Die beiden Importe aus der Toolbar.
|
||||
* The two import options.
|
||||
*
|
||||
* Notenimport: "UID<TAB>Note" je Zeile, schreibt nur die LV-Note.
|
||||
* Prüfungsimport: "UID<TAB>dd.MM.yyyy<TAB>Note" je Zeile, legt zusätzlich den Termin an.
|
||||
* Grade import: “UID<TAB>Grade” per line; writes only the course grade.
|
||||
* Exam import: “UID<TAB>dd.MM.yyyy<TAB>Grade” per line; also creates the exam date.
|
||||
*
|
||||
* Beide sind über CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT konfigurierbar - ist
|
||||
* der Import aus, existiert der Button nicht und der Block wird übersprungen.
|
||||
* Both can be configured via CIS_GESAMTNOTE_NOTENIMPORT / CIS_GESAMTNOTE_PRUEFUNGSIMPORT
|
||||
* if the import is disabled, the button does not appear and the block is skipped.
|
||||
*/
|
||||
context("Benotungstool UI - Import", () => {
|
||||
let ctx;
|
||||
@@ -36,36 +31,36 @@ context("Benotungstool UI - Import", () => {
|
||||
|
||||
beforeEach(function () {
|
||||
// im Punktemodus erwarten beide Importe Punkte statt einer Note
|
||||
requireNotenModus(this, ctx);
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
/**
|
||||
* Eine Note, deren Kürzel aus tbl_note.anmerkung im Import brauchbar ist: eindeutig, nicht leer
|
||||
* und nicht die Note selbst. Passt die Konfiguration nicht zum Parameter, kommt null zurück und
|
||||
* der Test überspringt sich. Nichts davon steht fest im Test, die Spalte ist Freitext.
|
||||
* A grade whose abbreviation from tbl_note.anmerkung can be used in the import: unique, not empty,
|
||||
* and not the grade itself. If the configuration does not match the parameter, null is returned and
|
||||
* the test skips this line.
|
||||
*/
|
||||
const kuerzelNote = () => {
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL) return null;
|
||||
|
||||
const kuerzelVon = (n) =>
|
||||
const kuerzelOf = (n) =>
|
||||
String(n.anmerkung ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
return (ctx.notenOptions ?? []).find((n) => {
|
||||
const k = kuerzelVon(n);
|
||||
const k = kuerzelOf(n);
|
||||
if (!n.lehre || k === "" || k === String(n.note).trim()) return false;
|
||||
|
||||
const doppelt = ctx.notenOptions.filter((o) => kuerzelVon(o) === k).length > 1;
|
||||
const alsNote = ctx.notenOptions.some((o) => String(o.note).trim() === k);
|
||||
const duplicate = ctx.notenOptions.filter((o) => kuerzelOf(o) === k).length > 1;
|
||||
const isNoteValue = ctx.notenOptions.some((o) => String(o.note).trim() === k);
|
||||
|
||||
return !doppelt && !alsNote;
|
||||
return !duplicate && !isNoteValue;
|
||||
});
|
||||
};
|
||||
|
||||
describe("Notenimport", () => {
|
||||
beforeEach(function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
});
|
||||
|
||||
it("schreibt die LV-Note für jede Zeile", () => {
|
||||
@@ -88,7 +83,7 @@ context("Benotungstool UI - Import", () => {
|
||||
});
|
||||
|
||||
it("legt dabei den ersten Antritt an", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -97,14 +92,14 @@ context("Benotungstool UI - Import", () => {
|
||||
|
||||
page.importNoten([[student.uid, ctx.gradeNotes[0]]]);
|
||||
|
||||
// die LV-Note IST Antritt 1, der Import schreibt ihn als eigene Zeile
|
||||
// The course grade IS “Attendance 1”; the import writes it as a separate row
|
||||
page.expectAntrittCount(student.uid, 1);
|
||||
page.expectPruefung(student.uid, "antritt_1", { note: ctx.gradeNotes[0], antritt: 1 });
|
||||
});
|
||||
|
||||
it("nimmt das Kürzel aus der Notenliste, sobald die Option das erlaubt", function () {
|
||||
const kuerzel = kuerzelNote();
|
||||
skipWenn(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung.");
|
||||
skipIf(this, !kuerzel, "Übersprungen: keine Note mit brauchbarem Kürzel in tbl_note.anmerkung.");
|
||||
|
||||
const student = ctx.students[1];
|
||||
|
||||
@@ -119,7 +114,7 @@ context("Benotungstool UI - Import", () => {
|
||||
|
||||
describe("Prüfungsimport", () => {
|
||||
beforeEach(function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
});
|
||||
|
||||
it("legt je Zeile einen datierten Antritt an", function () {
|
||||
@@ -127,7 +122,7 @@ context("Benotungstool UI - Import", () => {
|
||||
|
||||
const [a, b] = ctx.students;
|
||||
// das Format nennt die Konfiguration, nicht der Test
|
||||
const datum = page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
|
||||
const datum = page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, a, { note: ctx.notes.negativ });
|
||||
@@ -156,7 +151,7 @@ context("Benotungstool UI - Import", () => {
|
||||
page.importPruefungen([
|
||||
[
|
||||
student.uid,
|
||||
page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
|
||||
page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT),
|
||||
ctx.gradeNotes[0],
|
||||
],
|
||||
]);
|
||||
@@ -166,18 +161,18 @@ context("Benotungstool UI - Import", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Der Client prüft jede Zeile, bevor er sie schickt. Eine fehlerhafte Zeile meldet er als Warnung und
|
||||
// schickt nur die übrigen. Doppelte Zeilen erkennt er bewusst nicht.
|
||||
// The client checks each line before sending it. It reports any invalid lines as a warning and
|
||||
// sends only the remaining ones. It deliberately does not detect duplicate lines.
|
||||
describe("fehlerhafte Zeilen", () => {
|
||||
it("warnt im Prüfungsimport je fehlerhafter Zeile und schickt nur die gültigen", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const [perUid, perMatrikelnr, falschesDatum, falscheNote, falscheSpalten] = ctx.students;
|
||||
const [perUid, perMatrikelnr, wrongDate, wrongNote, wrongColumns] = ctx.students;
|
||||
const format = ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT;
|
||||
const datum = page.importDatum(attemptDate(ctx, 1), format);
|
||||
const jahr = attemptDate(ctx, 1).slice(0, 4);
|
||||
const unmoeglich = format === "yyyy-MM-dd" ? `${jahr}-02-31` : `31.02.${jahr}`;
|
||||
const datum = page.importDate(attemptDate(ctx, 1), format);
|
||||
const year = attemptDate(ctx, 1).slice(0, 4);
|
||||
const impossible = format === "yyyy-MM-dd" ? `${year}-02-31` : `31.02.${year}`;
|
||||
const note = ctx.notes.negativ;
|
||||
|
||||
expect(perMatrikelnr.matrikelnr, "Matrikelnummer des Studierenden").to.be.a("string").and.not.be.empty;
|
||||
@@ -191,18 +186,18 @@ context("Benotungstool UI - Import", () => {
|
||||
[perUid.uid, datum, note],
|
||||
[perMatrikelnr.matrikelnr, datum, note],
|
||||
["zz_unbekannt", datum, note],
|
||||
[falschesDatum.uid, unmoeglich, note],
|
||||
[falscheNote.uid, datum, "keine-note"],
|
||||
[falscheSpalten.uid, datum],
|
||||
[wrongDate.uid, impossible, note],
|
||||
[wrongNote.uid, datum, "keine-note"],
|
||||
[wrongColumns.uid, datum],
|
||||
]);
|
||||
|
||||
page.expectWarnung("zz_unbekannt");
|
||||
page.expectWarnung(falschesDatum.uid);
|
||||
page.expectWarnung(falscheNote.uid);
|
||||
page.expectWarning("zz_unbekannt");
|
||||
page.expectWarning(wrongDate.uid);
|
||||
page.expectWarning(wrongNote.uid);
|
||||
// die Zeile mit falscher Spaltenzahl nennt nur ihre Zeilennummer
|
||||
page.expectWarnungen(4);
|
||||
page.expectWarnings(4);
|
||||
|
||||
page.gesendeteUids("@savePruefungenBulk", "pruefungen").then((uids) => {
|
||||
page.sentUids("@savePruefungenBulk", "pruefungen").then((uids) => {
|
||||
expect(uids, "nur die gültigen Zeilen, die Matrikelnummer aufgelöst").to.deep.eq([
|
||||
perUid.uid,
|
||||
perMatrikelnr.uid,
|
||||
@@ -211,26 +206,26 @@ context("Benotungstool UI - Import", () => {
|
||||
});
|
||||
|
||||
it("warnt im Notenimport je fehlerhafter Zeile und schickt nur die gültigen", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const [gueltig, falscheNote, falscheSpalten] = ctx.students;
|
||||
const [valid, wrongNote, wrongColumns] = ctx.students;
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importNoten([
|
||||
[gueltig.uid, ctx.gradeNotes[0]],
|
||||
[valid.uid, ctx.gradeNotes[0]],
|
||||
["zz_unbekannt", ctx.gradeNotes[0]],
|
||||
[falscheNote.uid, "keine-note"],
|
||||
[falscheSpalten.uid, ctx.gradeNotes[0], "zu viel"],
|
||||
[wrongNote.uid, "keine-note"],
|
||||
[wrongColumns.uid, ctx.gradeNotes[0], "zu viel"],
|
||||
]);
|
||||
|
||||
page.expectWarnung("zz_unbekannt");
|
||||
page.expectWarnung(falscheNote.uid);
|
||||
page.expectWarnungen(3);
|
||||
page.expectWarning("zz_unbekannt");
|
||||
page.expectWarning(wrongNote.uid);
|
||||
page.expectWarnings(3);
|
||||
|
||||
page.gesendeteUids("@saveNotenvorschlagBulk", "noten").then((uids) => {
|
||||
expect(uids, "nur die gültige Zeile").to.deep.eq([gueltig.uid]);
|
||||
page.sentUids("@saveNotenvorschlagBulk", "noten").then((uids) => {
|
||||
expect(uids, "nur die gültige Zeile").to.deep.eq([valid.uid]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { notenAuth } from "../../../../support/api/notenApi";
|
||||
import { waitForOk } from "../../../../support/helpers/network";
|
||||
import {
|
||||
assistenzAuth,
|
||||
assistenzLvWithLehreinheiten,
|
||||
requireAssistenz,
|
||||
} from "../../../../support/helpers/notenAssistenz";
|
||||
import { loadNotenContext, requireDbReset } from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* The route is /Cis/Benotungstool/:sem_kurzbz?/:lv_id?. The semester comes before the course, so
|
||||
* the router no longer interprets a URL without a course as lv_id.
|
||||
*/
|
||||
context("Benotungstool UI - Navigation", () => {
|
||||
const SEMESTER = "[data-cy='dropdown-semester']";
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
/** The text that appears in the closed dropdown once the component has selected a semester. */
|
||||
const selectedSemester = () =>
|
||||
cy
|
||||
.get(`${SEMESTER} .p-dropdown-label`, { timeout: TIMEOUT })
|
||||
.should(($label) => expect($label.text().trim(), "vorausgewähltes Semester").to.not.be.empty)
|
||||
.then(($label) => $label.text().trim());
|
||||
|
||||
it("behält ein Semester ohne LV nach einem Reload", () => {
|
||||
const { username, password } = notenAuth();
|
||||
cy.login(username, password);
|
||||
cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getBenotungstoolContext*" }).as("context");
|
||||
cy.visit("/cis.php/Cis/Benotungstool");
|
||||
|
||||
selectedSemester()
|
||||
.then((preselected) => {
|
||||
cy.get(SEMESTER).click();
|
||||
return cy
|
||||
.get(".p-dropdown-panel .p-dropdown-item")
|
||||
.filter((i, el) => el.innerText.trim() !== preselected)
|
||||
.first();
|
||||
})
|
||||
.then(($item) => {
|
||||
const sem = $item.text().trim();
|
||||
|
||||
cy.wrap($item).click();
|
||||
waitForOk("@context");
|
||||
cy.location("pathname").should("match", new RegExp(`/Benotungstool/${sem}$`));
|
||||
|
||||
cy.reload();
|
||||
|
||||
selectedSemester().should("eq", sem);
|
||||
cy.get(".p-toast-message-error").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
// The list contained only the courses that the caller teaches.
|
||||
it("zeigt einer Assistenz die Lehreinheiten der LV", function () {
|
||||
requireAssistenz(this);
|
||||
|
||||
const { username, password } = assistenzAuth();
|
||||
|
||||
requireDbReset();
|
||||
loadNotenContext().then((ctx) => {
|
||||
cy.clearAllCookies();
|
||||
|
||||
assistenzLvWithLehreinheiten(ctx.semKurzbz).then((target) => {
|
||||
expect(target, "eine LV der Assistenz mit Lehreinheiten").to.not.be.null;
|
||||
|
||||
cy.login(username, password);
|
||||
cy.intercept({ method: "GET", url: "**/api/frontend/v1/Noten/getLehreinheitenFuerLv*" }).as(
|
||||
"lehreinheiten",
|
||||
);
|
||||
cy.visit(`/cis.php/Cis/Benotungstool/${target.sem}/${target.lvId}`);
|
||||
|
||||
waitForOk("@lehreinheiten");
|
||||
cy.get("[data-cy='dropdown-lehreinheit']", { timeout: TIMEOUT }).click();
|
||||
cy.get(".p-dropdown-panel .p-dropdown-item").should("have.length", target.lehreinheiten.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireKonfiguration, requireNotenModus } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requireNotenMode } from "../../../../support/helpers/notenConfig";
|
||||
import {
|
||||
attemptDate,
|
||||
baselineDate,
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Notenvorschlag eintragen und übernehmen.
|
||||
* Enter and apply the grade suggestion.
|
||||
*
|
||||
* Die Regeln dahinter prüft noten.notenvorschlag; hier geht es darum, dass die Tabelle den
|
||||
* Serverzustand ohne Reload korrekt nachführt: LV-Note, Freigabestatus und das Verschwinden des
|
||||
* Übernehmen-Buttons.
|
||||
* The rules behind this are checked by noten.notenvorschlag; the goal here is to ensure that the table
|
||||
* correctly updates the server state without a reload: course grade, approval status, and the disappearance of the
|
||||
* “Übernehmen” button.
|
||||
*/
|
||||
context("Benotungstool UI - Notenvorschlag", () => {
|
||||
let ctx;
|
||||
@@ -32,7 +32,7 @@ context("Benotungstool UI - Notenvorschlag", () => {
|
||||
|
||||
beforeEach(function () {
|
||||
// im Punktemodus ist die Vorschlagsspalte gesperrt, die Note kommt aus den Punkten
|
||||
requireNotenModus(this, ctx);
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
it("trägt einen Vorschlag über den Zelleneditor ein", () => {
|
||||
@@ -67,7 +67,7 @@ context("Benotungstool UI - Notenvorschlag", () => {
|
||||
// Das gewählte Datum ist das Datum von Antritt 1, nicht das benotungsdatum. Das benotungsdatum
|
||||
// bleibt der Zeitpunkt der Eingabe, sonst erschiene eine geänderte Note als freigegeben.
|
||||
it("schreibt das im Dialog gewählte Datum in den ersten Antritt", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME", true);
|
||||
|
||||
const student = ctx.students[2];
|
||||
const datum = baselineDate(ctx);
|
||||
@@ -88,11 +88,11 @@ context("Benotungstool UI - Notenvorschlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W7: Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Der
|
||||
// Eine einzelne Wiederholung ohne Antritt 1, wie sie die Studierendenverwaltung hinterlassen kann. Der
|
||||
// Button fehlt auch ohne Vorschlag, deshalb setzt der Test einen Vorschlag und prüft eine Kontrollzeile mit.
|
||||
it("zeigt bei einer einzelnen Wiederholung keinen Übernehmen-Button", () => {
|
||||
const wiederholung = ctx.students[3];
|
||||
const kontrolle = ctx.students[4];
|
||||
const control = ctx.students[4];
|
||||
// Sehr Gut zuerst: setNotenvorschlag vergleicht mit contains, "Gut" träfe "Sehr Gut"
|
||||
const vorschlag = ctx.gradeNotes[0];
|
||||
const pruefungsnote = ctx.gradeNotes.find(
|
||||
@@ -101,19 +101,19 @@ context("Benotungstool UI - Notenvorschlag", () => {
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, wiederholung, { erstantritt: false });
|
||||
seedBaseline(ctx, kontrolle);
|
||||
seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: attemptDate(ctx, 1), typ: "Termin2" });
|
||||
seedBaseline(ctx, control);
|
||||
seedPruefung(ctx, wiederholung, { note: pruefungsnote, datum: attemptDate(ctx, 1), type: "Termin2" });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setNotenvorschlag(kontrolle.uid, bezeichnung(vorschlag));
|
||||
page.getUebernehmenButton(kontrolle.uid).should("exist");
|
||||
page.setNotenvorschlag(control.uid, bezeichnung(vorschlag));
|
||||
page.getUebernehmenButton(control.uid).should("exist");
|
||||
|
||||
page.setNotenvorschlag(wiederholung.uid, bezeichnung(vorschlag));
|
||||
page.getUebernehmenButton(wiederholung.uid).should("not.exist");
|
||||
});
|
||||
|
||||
// C4: Die LV-Note ist nie 'entschuldigt', der Editor bietet die Note deshalb nicht an.
|
||||
// Die LV-Note ist nie 'entschuldigt', der Editor bietet die Note deshalb nicht an.
|
||||
it("bietet 'entschuldigt' nicht als Notenvorschlag an", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { benotungstoolPage as page } from "../../../../support/pages/benotungsto
|
||||
import { waitForOk } from "../../../../support/helpers/network";
|
||||
import {
|
||||
requireKommissionellerAntritt,
|
||||
requireKonfiguration,
|
||||
requireNotenModus,
|
||||
requireConfig,
|
||||
requireNotenMode,
|
||||
requireWiederholung,
|
||||
skipWenn,
|
||||
skipIf,
|
||||
} from "../../../../support/helpers/notenConfig";
|
||||
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
|
||||
import { addPruefung } from "../../../../support/helpers/notenScenario";
|
||||
import { addPruefung, givenBaseline } from "../../../../support/helpers/notenScenario";
|
||||
import {
|
||||
attemptDate,
|
||||
loadNotenContext,
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
} from "../../../../support/helpers/notenTestData";
|
||||
|
||||
/**
|
||||
* Prüfungen anlegen und bearbeiten, über beide Wege: den Dialog aus der Tabellenzelle und die
|
||||
* Sammelanlage aus der Toolbar. Beide laufen serverseitig durch denselben Kern
|
||||
* (siehe noten.pruefungstermin); geprüft wird, dass die Zelle danach ohne Reload stimmt.
|
||||
* Create and edit exams using both methods: the dialog from the table cell and the
|
||||
* batch creation from import. Both run on the server side using the same core
|
||||
* (see noten.pruefungstermin); the system verifies that the cell is correct afterward without reloading.
|
||||
*
|
||||
* Die Specs laufen im Terminmodus (Schaltfläche "Termine"), weil die Spaltennamen dort stabil sind:
|
||||
* antritt_1, antritt_2, ... Im Datumsmodus heisst die Spalte wie das Prüfungsdatum.
|
||||
* The specs run in schedule mode (the “Schedules” button) because the column names are consistent there:
|
||||
* antritt_1, antritt_2, ... In date mode, the column is named after the exam date.
|
||||
*/
|
||||
context("Benotungstool UI - Prüfungen", () => {
|
||||
let ctx;
|
||||
@@ -42,7 +42,7 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
|
||||
beforeEach(function () {
|
||||
// im Punktemodus ist das Notenfeld gesperrt, die Note kommt aus dem Notenschlüssel
|
||||
requireNotenModus(this, ctx);
|
||||
requireNotenMode(this, ctx);
|
||||
});
|
||||
|
||||
it("legt aus der Zelle eine Wiederholung an und zählt sie als Antritt 2", function () {
|
||||
@@ -68,11 +68,29 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
|
||||
// die Zelle zeigt den Freigabestatus, den der Server geschrieben hat; die Regel prüft noten.pruefungstermin
|
||||
readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
|
||||
const geaendert = new Date(row.benotungsdatum) > new Date(row.freigabedatum);
|
||||
page.expectFreigabeState(student.uid, geaendert ? "changed" : "ok");
|
||||
const changed = new Date(row.benotungsdatum) > new Date(row.freigabedatum);
|
||||
page.expectFreigabeState(student.uid, changed ? "changed" : "ok");
|
||||
});
|
||||
});
|
||||
|
||||
it("nennt nach einer positiven Note den Grund statt der Schaltfläche", function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENVERBESSERUNG", false);
|
||||
skipIf(
|
||||
this,
|
||||
!ctx.notes.positiv,
|
||||
"Übersprungen: keine positive Note, die die Kette nur ohne Notenverbesserung schliesst.",
|
||||
);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
givenBaseline(ctx, student, { note: ctx.notes.positiv });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.getBestandenHint(student.uid, "antritt_2").should("exist").and("have.attr", "title").and("not.be.empty");
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").should("not.exist");
|
||||
});
|
||||
|
||||
it("legt einen Antritt für einen Studenten ohne LV-Note an und weist darauf hin", () => {
|
||||
const student = ctx.students[2];
|
||||
|
||||
@@ -95,7 +113,7 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const neuesDatum = attemptDate(ctx, 2);
|
||||
const newDate = attemptDate(ctx, 2);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ });
|
||||
@@ -106,15 +124,15 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
note: bezeichnung(ctx.gradeNotes[1]),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, 1)),
|
||||
});
|
||||
page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(neuesDatum) });
|
||||
page.editPruefungInCell(student.uid, "antritt_2", { datum: page.toDDMMYYYY(newDate) });
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: ctx.gradeNotes[1], antritt: 2 });
|
||||
page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(neuesDatum));
|
||||
page.getCell(student.uid, "antritt_2").should("contain.text", page.toDDMMYYYY(newDate));
|
||||
});
|
||||
|
||||
it("sperrt die Note, sobald ein späterer Antritt existiert", function () {
|
||||
requireWiederholung(this, ctx);
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
|
||||
@@ -164,7 +182,7 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// W12: Der Sammeldialog prüft einen Termin am selben Tag wie der Server nach
|
||||
// Der Sammeldialog prüft einen Termin am selben Tag wie der Server nach
|
||||
// CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG.
|
||||
describe("Termin am selben Tag im Sammeldialog", () => {
|
||||
beforeEach(function () {
|
||||
@@ -172,7 +190,7 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
});
|
||||
|
||||
// 'entschuldigt' verbraucht keinen Antritt, deshalb bleibt ein weiterer Termin möglich
|
||||
const mitTerminAm = (student, datum) => {
|
||||
const withTerminOn = (student, datum) => {
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ });
|
||||
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum }).then((response) =>
|
||||
@@ -181,40 +199,40 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
page.visitAndWaitForTable(ctx);
|
||||
};
|
||||
|
||||
const sammelterminAbschicken = (student, datum) =>
|
||||
const submitBulkTermin = (student, datum) =>
|
||||
page.submitPruefungBulk({ uids: [student.uid], datum: page.toDDMMYYYY(datum) });
|
||||
|
||||
it("schickt einen Termin am selben Tag ab, wenn der Schalter an ist", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", true);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", true);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const datum = attemptDate(ctx, 1);
|
||||
|
||||
mitTerminAm(student, datum);
|
||||
sammelterminAbschicken(student, datum);
|
||||
withTerminOn(student, datum);
|
||||
submitBulkTermin(student, datum);
|
||||
|
||||
waitForOk("@createPruefungen");
|
||||
cy.get(".p-toast-message-warn").should("not.exist");
|
||||
});
|
||||
|
||||
it("warnt bei einem Termin am selben Tag und schickt nichts ab", function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false);
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG", false);
|
||||
|
||||
const student = ctx.students[0];
|
||||
const datum = attemptDate(ctx, 1);
|
||||
|
||||
mitTerminAm(student, datum);
|
||||
sammelterminAbschicken(student, datum);
|
||||
withTerminOn(student, datum);
|
||||
submitBulkTermin(student, datum);
|
||||
|
||||
cy.get(".p-toast-message-warn").should("contain.text", student.uid);
|
||||
page.getNeuePruefungModal().should("not.be.visible");
|
||||
page.getNewPruefungModal().should("not.be.visible");
|
||||
cy.get("@createPruefungen.all").should("have.length", 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("legt den letzten Antritt als kommissionelle Prüfung an", function () {
|
||||
requireKommissionellerAntritt(this, ctx);
|
||||
skipWenn(
|
||||
skipIf(
|
||||
this,
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT !== ctx.maxAntritte,
|
||||
"Übersprungen: der letzte Antritt ist nicht kommissionell.",
|
||||
@@ -237,21 +255,21 @@ context("Benotungstool UI - Prüfungen", () => {
|
||||
|
||||
// Der letzte Antritt ist laut Prüfungsordnung kommissionell. Er bekommt keine eigene Spalte,
|
||||
// sondern den nächsten Prüfungstermin. Das K in der Zelle kennzeichnet ihn.
|
||||
const letzterTermin = `antritt_${ctx.maxAntritte}`;
|
||||
const lastTermin = `antritt_${ctx.maxAntritte}`;
|
||||
|
||||
page.getPruefungAddButton(student.uid, letzterTermin).should("exist");
|
||||
page.getPruefungAddButton(student.uid, lastTermin).should("exist");
|
||||
|
||||
page.addPruefungInCell(student.uid, letzterTermin, {
|
||||
page.addPruefungInCell(student.uid, lastTermin, {
|
||||
note: bezeichnung(ctx.notes.negativ),
|
||||
datum: page.toDDMMYYYY(attemptDate(ctx, ctx.maxAntritte)),
|
||||
});
|
||||
|
||||
page.expectAntrittCount(student.uid, ctx.maxAntritte);
|
||||
// das Badge trägt beides: die Antrittsnummer und das K der kommissionellen Prüfung
|
||||
page.expectPruefung(student.uid, letzterTermin, {
|
||||
// The badge displays both the antritt number and the “K” for the commission exam
|
||||
page.expectPruefung(student.uid, lastTermin, {
|
||||
note: ctx.notes.negativ,
|
||||
antritt: `${ctx.maxAntritte}-K`,
|
||||
});
|
||||
page.expectKeineAntrittsspalte(ctx.maxAntritte + 1);
|
||||
page.expectNoAntrittColumn(ctx.maxAntritte + 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { benotungstoolPage as page } from "../../../../support/pages/benotungstool.po";
|
||||
import { requireKonfiguration, requirePunkteModus, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import { requireConfig, requirePunkteMode, requireWiederholung } from "../../../../support/helpers/notenConfig";
|
||||
import { attemptsOfStudent, readStateViaApi } from "../../../../support/helpers/notenScenario";
|
||||
import {
|
||||
attemptDate,
|
||||
@@ -13,22 +13,22 @@ import {
|
||||
import { notenApi } from "../../../../support/api/notenApi";
|
||||
|
||||
/**
|
||||
* Punktemodus (CIS_GESAMTNOTE_PUNKTE) in der Oberfläche.
|
||||
* Point Mode (CIS_GESAMTNOTE_PUNKTE) in the user interface.
|
||||
*
|
||||
* Die Punktespalte und die Punktefelder der beiden Dialoge existieren nur mit diesem Flag, die Note
|
||||
* wird dann aus dem Notenschlüssel abgeleitet statt gewählt. Genau diese Felder deckt sonst nichts
|
||||
* ab, deshalb steht der ganze Modus hier in einer eigenen Datei.
|
||||
* The points column and the points fields in both dialogs exist only when this flag is set; the grade
|
||||
* is then derived from the grading key instead of being selected. Nothing else covers these specific fields,
|
||||
* which is why this entire mode is contained in its own file here.
|
||||
*
|
||||
* Der Notenschlüssel wird nie hartcodiert: die erwartete Note kommt zur Laufzeit aus
|
||||
* getNoteByPunkte, damit die Specs an jeder Instanz mit eigenem Schlüssel laufen.
|
||||
* The grading key is never hard-coded: the expected grade is retrieved at runtime from
|
||||
* getNoteByPunkte, so that the specs run with their own key for each instance.
|
||||
*/
|
||||
context("Benotungstool UI - Punktemodus", () => {
|
||||
let ctx;
|
||||
// Punktewerte, deren Noten zur Laufzeit ermittelt werden
|
||||
const OBEN = 100;
|
||||
const MITTE = 70;
|
||||
let noteOben;
|
||||
let noteMitte;
|
||||
const TOP = 100;
|
||||
const MIDDLE = 70;
|
||||
let noteTop;
|
||||
let noteMiddle;
|
||||
|
||||
before(() => {
|
||||
requireDbReset();
|
||||
@@ -36,22 +36,22 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
ctx = context;
|
||||
if (!ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE) return;
|
||||
|
||||
notenApi.getNoteByPunkte(OBEN, ctx.lvId, ctx.semKurzbz).then((r) => {
|
||||
noteOben = r.body.data;
|
||||
expect(noteOben, `${OBEN} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
notenApi.getNoteByPunkte(TOP, ctx.lvId, ctx.semKurzbz).then((r) => {
|
||||
noteTop = r.body.data;
|
||||
expect(noteTop, `${TOP} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
});
|
||||
notenApi.getNoteByPunkte(MITTE, ctx.lvId, ctx.semKurzbz).then((r) => {
|
||||
noteMitte = r.body.data;
|
||||
expect(noteMitte, `${MITTE} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
expect(String(noteMitte), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
|
||||
String(noteOben),
|
||||
notenApi.getNoteByPunkte(MIDDLE, ctx.lvId, ctx.semKurzbz).then((r) => {
|
||||
noteMiddle = r.body.data;
|
||||
expect(noteMiddle, `${MIDDLE} Punkte müssen eine Note ergeben`).to.not.be.null;
|
||||
expect(String(noteMiddle), "die beiden Punktewerte müssen verschiedene Noten liefern").to.not.eq(
|
||||
String(noteTop),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
requirePunkteModus(this, ctx);
|
||||
requirePunkteMode(this, ctx);
|
||||
});
|
||||
|
||||
describe("Punktespalte in der Tabelle", () => {
|
||||
@@ -63,7 +63,7 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
|
||||
page.getPunkteCell(student.uid).should("exist");
|
||||
// die Note kommt aus den Punkten, sie darf nicht direkt gewählt werden
|
||||
page.expectNotenvorschlagGesperrt(student.uid);
|
||||
page.expectNotenvorschlagLocked(student.uid);
|
||||
});
|
||||
|
||||
it("schreibt Note und Punkte, wenn der Vorschlag übernommen wird", () => {
|
||||
@@ -72,15 +72,15 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.setPunkteInCell(student.uid, MITTE);
|
||||
page.setPunkteInCell(student.uid, MIDDLE);
|
||||
page.uebernehmen(student.uid);
|
||||
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMitte));
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteMiddle));
|
||||
page.expectFreigabeState(student.uid, "changed");
|
||||
|
||||
readLvGesamtnoteViaDb(ctx, student.uid).then((row) => {
|
||||
expect(String(row.note), "abgeleitete Note").to.eq(String(noteMitte));
|
||||
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MITTE);
|
||||
expect(String(row.note), "abgeleitete Note").to.eq(String(noteMiddle));
|
||||
expect(Number(row.punkte), "die Punkte werden mitgeschrieben").to.eq(MIDDLE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,17 +94,16 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
seedPruefung(ctx, student, {
|
||||
note: ctx.gradeNotes[0],
|
||||
datum: attemptDate(ctx, 1),
|
||||
typ: "Termin2",
|
||||
type: "Termin2",
|
||||
});
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.expectPunkteZelleGesperrt(student.uid);
|
||||
page.expectPunkteCellLocked(student.uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prüfungsdialog", () => {
|
||||
// Beide Tests brauchen einen freien Antritt: eine positive Basisnote schliesst die Kette.
|
||||
it("bietet ein Punktefeld statt der Notenauswahl", () => {
|
||||
const student = ctx.students[1];
|
||||
|
||||
@@ -116,7 +115,7 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
page.getPruefungModal().should("be.visible");
|
||||
|
||||
cy.get("[data-cy='pruefung-punkte']").should("be.visible");
|
||||
page.expectNoteFeldGesperrt("pruefung-note");
|
||||
page.expectNoteFieldLocked("pruefung-note");
|
||||
});
|
||||
|
||||
it("legt den Termin mit der aus den Punkten abgeleiteten Note an", () => {
|
||||
@@ -129,15 +128,15 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
page.getPruefungAddButton(student.uid, "antritt_2").click();
|
||||
page.getPruefungModal().should("be.visible");
|
||||
|
||||
page.setDatum("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
|
||||
page.setPruefungPunkte(OBEN);
|
||||
page.setDate("pruefung-datum", page.toDDMMYYYY(attemptDate(ctx, 1)));
|
||||
page.setPruefungPunkte(TOP);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
cy.wait("@saveStudentPruefung").its("response.statusCode").should("eq", 200);
|
||||
page.getPruefungModal().should("not.be.visible");
|
||||
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: noteOben, antritt: 2 });
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteOben));
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: noteTop, antritt: 2 });
|
||||
page.expectLvNote(student.uid, page.bezeichnungOf(ctx, noteTop));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,9 +152,11 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
seedBaseline(ctx, b, { note: ctx.notes.negativ, freigegeben: true });
|
||||
|
||||
page.visitAndWaitForTable(ctx);
|
||||
page.addPruefungBulk({ uids: [a.uid, b.uid], punkte: MITTE, datum: page.toDDMMYYYY(attemptDate(ctx, 1)) });
|
||||
page.addPruefungBulk({ uids: [a.uid, b.uid], punkte: MIDDLE, datum: page.toDDMMYYYY(attemptDate(ctx, 1)) });
|
||||
|
||||
[a, b].forEach((student) => page.expectPruefung(student.uid, "antritt_2", { note: noteMitte, antritt: 2 }));
|
||||
[a, b].forEach((student) =>
|
||||
page.expectPruefung(student.uid, "antritt_2", { note: noteMiddle, antritt: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,38 +167,38 @@ context("Benotungstool UI - Punktemodus", () => {
|
||||
[
|
||||
["Dezimalkomma", "89,5"],
|
||||
["Dezimalpunkt", "89.5"],
|
||||
].forEach(([schreibweise, eingabe]) => {
|
||||
it(`liest Punkte mit ${schreibweise} im Prüfungsimport`, function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
].forEach(([notation, input]) => {
|
||||
it(`liest Punkte mit ${notation} im Prüfungsimport`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_PRUEFUNGSIMPORT", true);
|
||||
requireWiederholung(this, ctx);
|
||||
|
||||
const student = ctx.students[4];
|
||||
const datum = page.importDatum(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
|
||||
const datum = page.importDate(attemptDate(ctx, 1), ctx.cisConfig.CIS_GESAMTNOTE_IMPORT_DATUMSFORMAT);
|
||||
|
||||
resetNotenState(ctx);
|
||||
seedBaseline(ctx, student, { note: ctx.notes.negativ, freigegeben: true });
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importPruefungen([[student.uid, datum, eingabe]]);
|
||||
page.importPruefungen([[student.uid, datum, input]]);
|
||||
|
||||
cy.get("@savePruefungenBulk").its("request.body.pruefungen.0.punkte").should("eq", PUNKTE);
|
||||
|
||||
readStateViaApi(ctx).then((data) => {
|
||||
const neu = attemptsOfStudent(data, student.uid)[1];
|
||||
expect(neu, "der importierte Termin").to.exist;
|
||||
expect(Number(neu.punkte), "die Punkte des Termins").to.eq(PUNKTE);
|
||||
const newTermin = attemptsOfStudent(data, student.uid)[1];
|
||||
expect(newTermin, "der importierte Termin").to.exist;
|
||||
expect(Number(newTermin.punkte), "die Punkte des Termins").to.eq(PUNKTE);
|
||||
});
|
||||
});
|
||||
|
||||
it(`liest Punkte mit ${schreibweise} im Notenimport`, function () {
|
||||
requireKonfiguration(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
it(`liest Punkte mit ${notation} im Notenimport`, function () {
|
||||
requireConfig(this, ctx, "CIS_GESAMTNOTE_NOTENIMPORT", true);
|
||||
|
||||
const student = ctx.students[5];
|
||||
|
||||
resetNotenState(ctx);
|
||||
page.visitAndWaitForTable(ctx);
|
||||
|
||||
page.importNoten([[student.uid, eingabe]]);
|
||||
page.importNoten([[student.uid, input]]);
|
||||
|
||||
cy.get("@saveNotenvorschlagBulk").its("request.body.noten.0.punkte").should("eq", PUNKTE);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Die §1-Regeln auf Clientseite. Pure functions - kein Server, keine Fixture.
|
||||
* The §1 rules on the client side as pure functions.
|
||||
*
|
||||
* Die Regeln liegen im Server und erreichen den Client als `student.verlauf`; hier wird nur das
|
||||
* Auswerten und der lokale Fallback (Verlauf noch nicht geladen) festgenagelt.
|
||||
* The rules are stored on the server and are sent to the client as `student.history`; here, only the
|
||||
* evaluation and the local fallback (history not yet loaded) are hard-coded.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
brauchtNeueLvNote,
|
||||
canAddPruefung,
|
||||
checkFreigabe,
|
||||
isBestanden,
|
||||
maxAntrittCount,
|
||||
} from "../../../../../public/js/components/Cis/Benotungstool/notenRules.js";
|
||||
|
||||
@@ -47,21 +48,21 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
});
|
||||
|
||||
it("nimmt 1, solange die Konfiguration keine Zahl liefert", () => {
|
||||
// getCisConfig liefert den Schlüssel erst nach dem Laden; bis dahin gilt ein Antritt
|
||||
// getCisConfig returns the key only after loading; until then, a default value applies
|
||||
expect(maxAntrittCount({}), "Schlüssel fehlt").to.eq(1);
|
||||
expect(maxAntrittCount({ CIS_GESAMTNOTE_MAX_ANTRITTE: null }), "Schlüssel ist null").to.eq(1);
|
||||
expect(maxAntrittCount(undefined), "keine Konfiguration").to.eq(1);
|
||||
expect(maxAntrittCount(null), "Konfiguration ist null").to.eq(1);
|
||||
});
|
||||
|
||||
// Der Rückfall nutzt ??, nicht ||. Mit || würde eine konfigurierte 0 zu 1 werden und das
|
||||
// Werkzeug liesse einen Antritt zu, den die Konfiguration verbietet.
|
||||
// The fallback uses ??, not ||. If || were used, a configured 0 would become 1, and the
|
||||
// tool would allow a start that the configuration prohibits.
|
||||
it("behält eine konfigurierte 0", () => {
|
||||
expect(maxAntrittCount(configWith(0))).to.eq(0);
|
||||
});
|
||||
|
||||
// getMaxAntritte auf dem Server liefert ein int. Die Umwandlung hält den Wert auch dann eine
|
||||
// Zahl, wenn er je als Text ankommt: die Aufrufer vergleichen ihn, die Tests streng.
|
||||
// getMaxAntritte on the server returns an int. The conversion ensures that the value remains a
|
||||
// number even if it is received as text: the callers compare it, and the tests are strict.
|
||||
it("gibt immer eine Zahl zurück", () => {
|
||||
expect(maxAntrittCount(configWith("4")), "Text mit Zahl").to.eq(4);
|
||||
expect(maxAntrittCount(configWith(3)), "bleibt eine Zahl").to.eq(3);
|
||||
@@ -94,10 +95,10 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
expect(antrittCountStudent(student({ note: 1 }), ours, notenOptions)).to.eq(1);
|
||||
});
|
||||
|
||||
// dieselbe Falle wie in brauchtNeueLvNote: die Note 0 ist eine Note, kein fehlender Wert
|
||||
// Same pitfall as in “needsNewLvNote”: a score of 0 is a score, not a missing value
|
||||
it("zählt auch die Note 0 als ersten Antritt", () => {
|
||||
const mitNullNote = [...notenOptions, { note: 0, lehre: true }];
|
||||
expect(antrittCountStudent(student({ note: 0 }), ours, mitNullNote)).to.eq(1);
|
||||
const withZeroNote = [...notenOptions, { note: 0, lehre: true }];
|
||||
expect(antrittCountStudent(student({ note: 0 }), ours, withZeroNote)).to.eq(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -109,7 +110,7 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
});
|
||||
|
||||
it("sperrt eine angerechnete Zeile trotz freier Antritte", () => {
|
||||
// the server sets canAdd:false for an Anrechnung; the client must not second-guess it
|
||||
// the server sets canAdd: false for an Anrechnung, the client must not second-guess it
|
||||
const s = withVerlauf({ canAdd: false, antrittCount: 0, angerechnet: true });
|
||||
expect(canAddPruefung(s, ours)).to.be.false;
|
||||
});
|
||||
@@ -124,8 +125,8 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
// Bekannte Lücke, hier festgenagelt: die LV-Note zählt nur solange gar keine Prüfung
|
||||
// existiert. Deshalb schreibt die Freigabe Antritt 1 als echte Zeile.
|
||||
// Known issue, addressed here: the course grade only counts if there is no exam
|
||||
// at all. That is why the approval writes “Attempt 1” as a separate row.
|
||||
it("zählt die LV-Note nicht mehr mit, sobald eine Prüfung existiert", () => {
|
||||
const s = student({ note: 1, pruefungen: [{ note: 5 }] });
|
||||
expect(antrittCountStudent(s, ours, notenOptions), "the LV note is no longer added").to.eq(1);
|
||||
@@ -133,12 +134,21 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBestanden", () => {
|
||||
it("folgt dem Verlauf und bleibt ohne Verlauf false", () => {
|
||||
expect(isBestanden(withVerlauf({ canAdd: false, bestanden: true }))).to.be.true;
|
||||
expect(isBestanden(withVerlauf({ canAdd: false, bestanden: false }))).to.be.false;
|
||||
// the client cannot tell a pass without the server rules
|
||||
expect(isBestanden(student({ note: 1 }))).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("brauchtNeueLvNote", () => {
|
||||
/**
|
||||
* Der Verlauf schlägt lv_note, und zwar genau deshalb: lv_note trägt nur FREIGEGEBENE Noten
|
||||
* (Benotungstool.js setzt es aus getLvGesamtNoten), hatLvNote dagegen zählt auch die
|
||||
* eingetragene, noch nicht freigegebene Note mit. Noten.php nennt das Feld
|
||||
* "ungefiltert, also inklusive noch nicht freigegebener".
|
||||
* The history uses `lv_note`: `lv_note` only contains APPROVED grades
|
||||
* (`Benotungstool.js` calculates it from `getLvGesamtNoten`), whereas `hatLvNote` also counts the
|
||||
* entered grades that have not yet been approved. Noten.php refers to this field as
|
||||
* “unfiltered, i.e., including those not yet approved.”
|
||||
*/
|
||||
it("meldet keinen Bedarf, wenn die LV-Note nur noch nicht freigegeben ist", () => {
|
||||
const s = { verlauf: { hatLvNote: true }, lv_note: null };
|
||||
@@ -146,13 +156,13 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
});
|
||||
|
||||
it("meldet Bedarf, wenn der Verlauf keine LV-Note kennt", () => {
|
||||
// auch wenn lv_note gesetzt ist: der Verlauf entscheidet
|
||||
// even if lv_note is set, the history takes precedence
|
||||
expect(brauchtNeueLvNote({ verlauf: { hatLvNote: false }, lv_note: 1 })).to.be.true;
|
||||
expect(brauchtNeueLvNote({ verlauf: { hatLvNote: false }, lv_note: null })).to.be.true;
|
||||
});
|
||||
|
||||
// verlaufSummary hat für hatLvNote den Vorgabewert null. Ein Aufrufer, der ihn nicht
|
||||
// übergibt, erzeugt damit den Hinweis "noch keine LV-Note".
|
||||
// verlaufSummary has a default value of zero for hatLvNote. A caller who does not
|
||||
// pass it will thus trigger the message “No LV note yet.”
|
||||
it("meldet Bedarf, wenn der Verlauf das Feld nicht trägt", () => {
|
||||
expect(brauchtNeueLvNote({ verlauf: {} })).to.be.true;
|
||||
});
|
||||
@@ -164,8 +174,8 @@ describe("Benotungstool-Regeln (Client)", () => {
|
||||
expect(brauchtNeueLvNote({}), "Feld fehlt").to.be.true;
|
||||
});
|
||||
|
||||
// Die Note 0 ("Teilnote") ist eine Note. Ein truthy-Test hielte sie für "keine Note" und
|
||||
// das Werkzeug verspräche eine neue LV-Note, obwohl eine existiert.
|
||||
// A grade of 0 (“partial grade”) is a grade. A truthy test would treat it as “no grade,” and
|
||||
// the tool would promise a new course grade even though one already exists.
|
||||
it("erkennt die Note 0 als Note", () => {
|
||||
expect(brauchtNeueLvNote({ lv_note: 0 })).to.be.false;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Local workstation: SSH tunnel to the database and configuration profiles via SFTP.
|
||||
# Copy to tests/cypress/local/.env. The suite itself never reads this file.
|
||||
|
||||
# ---- SSH tunnel (withTunnel.js) --------------------- ---------------------------------------------
|
||||
# pg_hba.conf only allows the application server. NOTEN_DB_HOST in suites/.env remains the ACTUAL database.
|
||||
# - The SSH host is usually NOT the web hostname
|
||||
# - A key outside the OpenSSH default names is never automatically tried; specify it here,
|
||||
# or leave NOTEN_SSH_KEY empty for the SSH agent
|
||||
NOTEN_SSH_HOST=
|
||||
NOTEN_SSH_PORT=22
|
||||
NOTEN_SSH_USER=
|
||||
NOTEN_SSH_KEY=
|
||||
NOTEN_SSH_PASSPHRASE=
|
||||
|
||||
# ---- Configuration profiles (notenProfiles.js) ------------------------------------------------ ---
|
||||
# Absolute path of the instance on its host.
|
||||
NOTEN_REMOTE_ROOT=
|
||||
# Only if the instance is on a different host than the tunnel. With NOTEN_PROFILE_SSH_HOST, every
|
||||
# SSH value in the profiles comes from NOTEN_PROFILE_SSH_*; an empty key uses the SSH agent.
|
||||
NOTEN_PROFILE_SSH_HOST=
|
||||
NOTEN_PROFILE_SSH_USER=
|
||||
NOTEN_PROFILE_SSH_KEY=
|
||||
|
||||
# Only for an instance with a self-signed certificate: otherwise, the Node side will reject it.
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
@@ -0,0 +1,87 @@
|
||||
const js = require("@eslint/js");
|
||||
const globals = require("globals");
|
||||
const pluginCypress = require("eslint-plugin-cypress");
|
||||
const prettier = require("eslint-config-prettier");
|
||||
|
||||
/**
|
||||
* Nur die Cypress-Suite. Der übrige Code des Repos ist älter als dieses Setup; ihn mitzulinten
|
||||
* würde hunderte Funde erzeugen, die niemand in dieser Story abarbeitet.
|
||||
*
|
||||
* Die no-restricted-syntax-Regeln setzen die Konventionen aus .claude/rules/benotungstool-tests.md durch.
|
||||
* Sie sind der Grund für diese Datei: eine Konvention, die kein Werkzeug prüft, zerfällt.
|
||||
*
|
||||
* Pfade gelten ab dem Repo-Root: `npm run lint` in local/ wechselt dorthin.
|
||||
*/
|
||||
module.exports = [
|
||||
{ ignores: ["tests/cypress/e2e/fhcomplete/**", "tests/cypress/e2e/*.cy.js"] },
|
||||
|
||||
// --- Node-Seite: Tasks, Werkzeuge, Profile, eigener Arbeitsplatz, Konfiguration (CommonJS) ---
|
||||
{
|
||||
files: ["tests/cypress/{tasks,tools,profiles,suites,local}/**/*.js", "cypress.config.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "commonjs",
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Browser-Seite: Specs und Support (ES-Module, im Cypress-Runner) ---
|
||||
{
|
||||
files: ["tests/cypress/e2e/**/*.js", "tests/cypress/support/**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
globals: { ...globals.browser, ...globals.mocha, cy: "readonly", Cypress: "readonly", expect: "readonly" },
|
||||
},
|
||||
plugins: { cypress: pluginCypress },
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...pluginCypress.configs.recommended.rules,
|
||||
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", caughtErrors: "none" }],
|
||||
|
||||
/*
|
||||
* Warnung statt Fehler. Das Muster .clear().type() steckt an vier Stellen im Page
|
||||
* Object und läuft dort. Drei davon gehören zum Punktemodus, den nur das Profil
|
||||
* "punkte" ausführt - eine Umstellung wäre hier nicht prüfbar. Für neuen Code
|
||||
* bleibt der Hinweis sichtbar.
|
||||
*/
|
||||
"cypress/unsafe-to-chain-command": "warn",
|
||||
},
|
||||
},
|
||||
|
||||
// --- die Konventionen der Suite ---
|
||||
{
|
||||
files: ["tests/cypress/e2e/specs/**/*.cy.js"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
// skipIf / require* aus notenConfig.js sind der einzige Weg.
|
||||
selector: "CallExpression[callee.property.name='skip'][callee.object.type='ThisExpression']",
|
||||
message:
|
||||
"Kein this.skip() im Spec. Nimm skipIf(this, condition, reason) oder einen require*-Helfer aus support/helpers/notenConfig.js.",
|
||||
},
|
||||
{
|
||||
// Die Noten-API zu stubben hebt genau die Serverregeln auf, die die Suite prüft.
|
||||
selector:
|
||||
"CallExpression[callee.object.name='cy'][callee.property.name='intercept'] > ObjectExpression:has(Property[key.name='body'])",
|
||||
message:
|
||||
"Kein Stub der Noten-API. cy.intercept nur zum Aliasen und Warten, siehe .claude/rules/benotungstool-tests.md.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// notenConfig.js hält die einzige Umsetzung von skip.
|
||||
{
|
||||
files: ["tests/cypress/support/helpers/notenConfig.js"],
|
||||
rules: { "no-restricted-syntax": "off" },
|
||||
},
|
||||
|
||||
// muss zuletzt stehen: schaltet alle Stilregeln ab, die Prettier ohnehin setzt
|
||||
prettier,
|
||||
];
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Configuration profiles for the grading tool suite, via SFTP on a c3p0 instance.
|
||||
*
|
||||
* node tests/cypress/local/notenProfiles.js status Profiles and the active profile
|
||||
* node tests/cypress/local/notenProfiles.js apply <profile> For example, before running `cypress open`
|
||||
* node tests/cypress/local/notenProfiles.js restore restore the originals
|
||||
* node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] one run per profile, followed by the report
|
||||
*
|
||||
* The profiles are located in tests/cypress/profiles/noten.js. The `run` command opens the SSH tunnel itself.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
|
||||
["local", "suites", "."].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") }));
|
||||
|
||||
const profileSwitch = require("./profileSwitch");
|
||||
const { openDbTunnel } = require("./withTunnel");
|
||||
const { profiles } = require("../profiles/noten");
|
||||
|
||||
const SCOPES = {
|
||||
api: { spec: "tests/cypress/e2e/specs/{unit,api}/**/*.cy.js" },
|
||||
// Electron crashes on the grading tool page
|
||||
ui: { spec: "tests/cypress/e2e/specs/ui/**/*.cy.js", browser: "chrome" },
|
||||
all: { spec: "tests/cypress/e2e/specs/**/*.cy.js", browser: "chrome" },
|
||||
};
|
||||
|
||||
const NOT_RUN = ["pending", "skipped"];
|
||||
|
||||
const testTitle = (run, test) => `${path.basename(run.spec.relative)} › ${test.title.join(" › ")}`;
|
||||
|
||||
const column = (value, width) => String(value).padStart(width);
|
||||
|
||||
/** Numbers per profile, the red tests per profile, and the tests that aren't running in any profile. */
|
||||
const report = (names, results) => {
|
||||
const tests = new Map();
|
||||
const completed = [];
|
||||
let failed = false;
|
||||
|
||||
console.log(`\n${"=".repeat(78)}\n${"Profil".padEnd(20)} Tests grün rot pending\n${"-".repeat(78)}`);
|
||||
|
||||
names.forEach((name) => {
|
||||
const result = results[name];
|
||||
if (!result || !Array.isArray(result.runs)) {
|
||||
failed = true;
|
||||
console.log(`${name.padEnd(20)} Lauf gescheitert: ${result ? result.message : "kein Ergebnis"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
completed.push(name);
|
||||
const counts = { passed: 0, failed: 0, pending: 0, skipped: 0 };
|
||||
result.runs.forEach((run) =>
|
||||
run.tests.forEach((test) => {
|
||||
counts[test.state] = (counts[test.state] || 0) + 1;
|
||||
const key = testTitle(run, test);
|
||||
if (!tests.has(key)) tests.set(key, {});
|
||||
tests.get(key)[name] = test;
|
||||
}),
|
||||
);
|
||||
|
||||
if (counts.failed) failed = true;
|
||||
const total = counts.passed + counts.failed + counts.pending + counts.skipped;
|
||||
console.log(
|
||||
`${name.padEnd(20)} ${column(total, 5)} ${column(counts.passed, 6)} ${column(counts.failed, 6)} ${column(counts.pending + counts.skipped, 7)}`,
|
||||
);
|
||||
});
|
||||
|
||||
completed.forEach((name) => {
|
||||
const failures = [...tests.entries()].filter(
|
||||
([, byProfile]) => byProfile[name] && byProfile[name].state === "failed",
|
||||
);
|
||||
if (!failures.length) return;
|
||||
|
||||
console.log(`\nRot in "${name}":`);
|
||||
failures.forEach(([key, byProfile]) =>
|
||||
console.log(` - ${key}\n ${String(byProfile[name].displayError || "").split("\n")[0]}`),
|
||||
);
|
||||
});
|
||||
|
||||
const neverRun = [...tests.keys()].filter((key) =>
|
||||
completed.every((name) => !tests.get(key)[name] || NOT_RUN.includes(tests.get(key)[name].state)),
|
||||
);
|
||||
console.log(`\nIn keinem Profil gelaufen (${neverRun.length}):`);
|
||||
neverRun.forEach((key) => console.log(` - ${key}`));
|
||||
console.log("=".repeat(78));
|
||||
|
||||
return failed ? 1 : 0;
|
||||
};
|
||||
|
||||
const runProfiles = async (scope = "api", selection = null) => {
|
||||
const options = SCOPES[scope];
|
||||
if (!options) {
|
||||
console.error(`Unbekannter Umfang "${scope}". Erlaubt: ${Object.keys(SCOPES).join(", ")}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const names = selection
|
||||
? selection
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean)
|
||||
: Object.keys(profiles);
|
||||
names.forEach((name) => profileSwitch.profileData(name));
|
||||
|
||||
const cypress = require("cypress");
|
||||
const results = {};
|
||||
const closeTunnel = await openDbTunnel();
|
||||
|
||||
try {
|
||||
for (const name of names) {
|
||||
console.log(`\n### Profil "${name}": ${profiles[name].description}\n`);
|
||||
try {
|
||||
await profileSwitch.apply(name);
|
||||
results[name] = await cypress.run(options);
|
||||
} catch (error) {
|
||||
// ein gescheiterter Profilwechsel beendet nur diesen Lauf; der Bericht nennt den Grund
|
||||
results[name] = { message: error.message };
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await profileSwitch.restore();
|
||||
await closeTunnel();
|
||||
}
|
||||
|
||||
return report(names, results);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const [command, first, second] = process.argv.slice(2);
|
||||
|
||||
if (command === "run") return runProfiles(first, second);
|
||||
|
||||
if (command === "apply" && first) {
|
||||
await profileSwitch.apply(first);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === "restore") {
|
||||
if (!(await profileSwitch.restore())) console.log("Kein Profil war aktiv.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
const state = await profileSwitch.status();
|
||||
console.log(state && state.profile ? `\nAktiv auf ${state.root}: "${state.profile}"` : "\nKein Profil aktiv.");
|
||||
console.log("\nProfile:");
|
||||
Object.entries(profiles).forEach(([name, p]) => console.log(` ${name.padEnd(18)} ${p.description}`));
|
||||
console.log("");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"\n node tests/cypress/local/notenProfiles.js status | apply <profile> | restore | run [api|ui|all] [profile,profile]\n",
|
||||
);
|
||||
return 2;
|
||||
};
|
||||
|
||||
main()
|
||||
.then((code) => process.exit(code))
|
||||
.catch((error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"private": true,
|
||||
"description": "Eigener Arbeitsplatz: SSH-Tunnel, Profile, Lint und Format.",
|
||||
"scripts": {
|
||||
"lint": "cd ../../.. && eslint -c tests/cypress/local/eslint.config.js tests/cypress cypress.config.js",
|
||||
"format": "cd ../../.. && prettier --config tests/cypress/local/package.json --write \"tests/cypress/**/*.js\" \"!tests/cypress/e2e/fhcomplete/**\" \"!tests/cypress/e2e/*.cy.js\""
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
},
|
||||
"dependencies": {
|
||||
"ssh2": "^1.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-cypress": "^7.0.2",
|
||||
"globals": "^17.12.0",
|
||||
"prettier": "^3.9.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Configuration profiles for the grading tool suite on the test instance.
|
||||
*
|
||||
* A profile modifies application/config/noten.php and the define() switches in
|
||||
* config/global.config.inc.php. The originals are located in the SSH user's home directory, i.e.,
|
||||
* outside the web root: global.config.inc.php contains login credentials.
|
||||
*
|
||||
* state.json stores the MD5 hash of every file that has been written. If a file differs from this hash, it has been
|
||||
* redeployed since then. It is then considered the original, and the old backup is invalidated.
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
const { resolveAuth } = require("./sshTunnel");
|
||||
const { base, profiles } = require("../profiles/noten");
|
||||
|
||||
const PREFIX = process.env.TEST_ENV_PREFIX || "TEST";
|
||||
|
||||
// The instance may be located on a different host than the SSH tunnel to the database. If
|
||||
// <PREFIX>_PROFILE_SSH_HOST is set, all SSH values come from <PREFIX>_PROFILE_SSH_*; without a key,
|
||||
// the SSH agent is used.
|
||||
const envValue = (key) => {
|
||||
const ownHost = key.startsWith("SSH_") && Boolean(process.env[`${PREFIX}_PROFILE_SSH_HOST`]);
|
||||
return process.env[`${PREFIX}_${ownHost ? "PROFILE_" : ""}${key}`];
|
||||
};
|
||||
|
||||
const FILES = {
|
||||
config: "application/config/noten.php",
|
||||
flags: "config/global.config.inc.php",
|
||||
};
|
||||
|
||||
// relative to the home directory; that's where every SFTP session starts
|
||||
const BACKUP_DIR = ".cypress-noten-profile";
|
||||
const STATE_FILE = `${BACKUP_DIR}/state.json`;
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9-]+$/;
|
||||
const KEY_PATTERN = /^[A-Z0-9_]+$/;
|
||||
|
||||
const md5 = (content) => crypto.createHash("md5").update(content).digest("hex");
|
||||
|
||||
const sftpCall = (fn) => new Promise((resolve, reject) => fn((err, value) => (err ? reject(err) : resolve(value))));
|
||||
|
||||
const sshConfigured = () => Boolean(envValue("SSH_HOST") && envValue("SSH_USER"));
|
||||
|
||||
// --- SFTP ---
|
||||
|
||||
const connect = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const { Client } = require("ssh2");
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = resolveAuth({
|
||||
keyPath: envValue("SSH_KEY"),
|
||||
passphrase: envValue("SSH_PASSPHRASE"),
|
||||
agent: envValue("SSH_AGENT"),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
client
|
||||
.on("ready", () => client.sftp((err, sftp) => (err ? reject(err) : resolve({ client, sftp }))))
|
||||
.on("error", (err) =>
|
||||
reject(new Error(`SSH zu ${envValue("SSH_USER")}@${envValue("SSH_HOST")} gescheitert: ${err.message}`)),
|
||||
)
|
||||
.connect({
|
||||
host: envValue("SSH_HOST"),
|
||||
port: Number(envValue("SSH_PORT") || 22),
|
||||
username: envValue("SSH_USER"),
|
||||
privateKey: auth.privateKey,
|
||||
passphrase: auth.passphrase,
|
||||
agent: auth.agent,
|
||||
readyTimeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
const withSftp = async (fn) => {
|
||||
const { client, sftp } = await connect();
|
||||
try {
|
||||
return await fn(sftp);
|
||||
} finally {
|
||||
client.end();
|
||||
}
|
||||
};
|
||||
|
||||
const exists = (sftp, file) => new Promise((resolve) => sftp.stat(file, (err) => resolve(!err)));
|
||||
const read = (sftp, file) => sftpCall((cb) => sftp.readFile(file, cb));
|
||||
const writeRaw = (sftp, file, content, mode) => sftpCall((cb) => sftp.writeFile(file, content, { mode }, cb));
|
||||
|
||||
/** Writes to a temporary file to ensure that no request reads an incomplete configuration. */
|
||||
const write = async (sftp, file, content) => {
|
||||
// .php: ein Webserver führt die Datei aus, statt ihre Zugangsdaten auszuliefern
|
||||
const temp = `${file}.cypress-temp.php`;
|
||||
await writeRaw(sftp, temp, content, 0o644);
|
||||
await sftpCall((cb) => sftp.ext_openssh_rename(temp, file, cb));
|
||||
};
|
||||
|
||||
const backupOf = (remotePath) => `${BACKUP_DIR}/${path.posix.basename(remotePath)}`;
|
||||
|
||||
const readState = async (sftp) =>
|
||||
(await exists(sftp, STATE_FILE)) ? JSON.parse((await read(sftp, STATE_FILE)).toString("utf8")) : null;
|
||||
|
||||
const writeState = (sftp, state) => writeRaw(sftp, STATE_FILE, JSON.stringify(state, null, 2), 0o600);
|
||||
|
||||
// --- Modifying the files ---
|
||||
|
||||
const phpString = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
||||
|
||||
const phpValue = (value) => {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return phpString(value);
|
||||
throw new Error(`Ein define()-Schalter nimmt nur einen einfachen Wert, nicht ${JSON.stringify(value)}.`);
|
||||
};
|
||||
|
||||
// ASCII only: this block does not change the file's encoding
|
||||
const asciiJson = (value) =>
|
||||
JSON.stringify(value).replace(/[-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
||||
|
||||
/** Appends the profile keys to noten.php. The later assignment takes precedence. */
|
||||
const withConfig = (original, name, config) => {
|
||||
const text = original.toString("latin1");
|
||||
const block =
|
||||
`\n// cypress-profil "${name}": nur auf der Testinstanz, die Suite stellt das Original wieder her\n` +
|
||||
`$config = array_replace($config, json_decode(${phpString(asciiJson(config))}, true));\n`;
|
||||
const end = text.search(/\?>\s*$/);
|
||||
|
||||
return Buffer.from(end === -1 ? text + block : text.slice(0, end) + block + text.slice(end), "latin1");
|
||||
};
|
||||
|
||||
/** Replaces the `define()` line for each switch. A second `define()` outputs a notice to the page. */
|
||||
const withFlags = (original, flags) => {
|
||||
let text = original.toString("latin1");
|
||||
|
||||
Object.entries(flags).forEach(([flag, value]) => {
|
||||
const pattern = new RegExp(`^([ \\t]*)define\\(\\s*['"]${flag}['"]\\s*,[^;]*;`, "m");
|
||||
if (!pattern.test(text)) {
|
||||
throw new Error(
|
||||
`${FILES.flags} definiert ${flag} nicht. Ein Profil ändert nur einen bestehenden Schalter.`,
|
||||
);
|
||||
}
|
||||
text = text.replace(pattern, (match, indent) => `${indent}define('${flag}', ${phpValue(value)});`);
|
||||
});
|
||||
|
||||
return Buffer.from(text, "latin1");
|
||||
};
|
||||
|
||||
// --- Profile ---
|
||||
|
||||
/** Profile, including the base. expect lists values from getCisConfig that a flag changes only indirectly. */
|
||||
const profileData = (name) => {
|
||||
if (!NAME_PATTERN.test(String(name)) || !Object.prototype.hasOwnProperty.call(profiles, name)) {
|
||||
throw new Error(`Unbekanntes Profil "${name}". Vorhanden: ${Object.keys(profiles).join(", ")}`);
|
||||
}
|
||||
|
||||
const data = {
|
||||
config: { ...base.config, ...profiles[name].config },
|
||||
flags: { ...base.flags, ...profiles[name].flags },
|
||||
expect: { ...profiles[name].expect },
|
||||
};
|
||||
|
||||
[...Object.keys(data.config), ...Object.keys(data.flags)].forEach((key) => {
|
||||
if (!KEY_PATTERN.test(key)) throw new Error(`Ungültiger Schlüssel "${key}" in Profil "${name}".`);
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/** Stellt eine Datei her, die ein Profil geschrieben hat. Eine neu ausgerollte Datei bleibt stehen. */
|
||||
const restoreFile = async (sftp, state, fileKey) => {
|
||||
const entry = state.files[fileKey];
|
||||
if (!entry) return;
|
||||
|
||||
const remotePath = `${state.root}/${FILES[fileKey]}`;
|
||||
const backup = backupOf(remotePath);
|
||||
|
||||
if (await exists(sftp, backup)) {
|
||||
if (md5(await read(sftp, remotePath)) === entry.md5) {
|
||||
await write(sftp, remotePath, await read(sftp, backup));
|
||||
} else {
|
||||
console.warn(`[profile] ${remotePath} ist seit dem Profil neu ausgerollt und bleibt stehen.`);
|
||||
}
|
||||
await sftpCall((cb) => sftp.unlink(backup, cb));
|
||||
}
|
||||
|
||||
delete state.files[fileKey];
|
||||
await writeState(sftp, state);
|
||||
};
|
||||
|
||||
/** Creates a file generated by a profile. A newly rolled-out file remains in place. */
|
||||
const applyFile = async (sftp, state, fileKey, transform) => {
|
||||
const remotePath = `${state.root}/${FILES[fileKey]}`;
|
||||
const backup = backupOf(remotePath);
|
||||
const current = await read(sftp, remotePath);
|
||||
const entry = state.files[fileKey];
|
||||
const fromProfile = Boolean(entry) && md5(current) === entry.md5;
|
||||
|
||||
let original = current;
|
||||
if (fromProfile) {
|
||||
if (!(await exists(sftp, backup))) {
|
||||
throw new Error(`Die Sicherung ${backup} fehlt. Das Original von ${remotePath} ist unbekannt.`);
|
||||
}
|
||||
original = await read(sftp, backup);
|
||||
} else {
|
||||
if (entry)
|
||||
console.warn(`[profile] ${remotePath} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`);
|
||||
await writeRaw(sftp, backup, current, 0o600);
|
||||
}
|
||||
|
||||
const updated = transform(original);
|
||||
|
||||
// zuerst der Zustand: bricht das Schreiben ab, gilt die Datei beim nächsten Lauf als Original
|
||||
state.files[fileKey] = { md5: md5(updated) };
|
||||
await writeState(sftp, state);
|
||||
await write(sftp, remotePath, updated);
|
||||
};
|
||||
|
||||
const matches = (actual, expected) =>
|
||||
typeof expected === "boolean" ? Boolean(actual) === expected : JSON.stringify(actual) === JSON.stringify(expected);
|
||||
|
||||
/** The data returned by getCisConfig, or null with a reason in the event of a temporary error (network, HTTP 5xx). */
|
||||
const readCisConfig = async (url, auth) => {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { headers: { Authorization: auth } });
|
||||
} catch (error) {
|
||||
return { actual: null, reason: error.message };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
const actual = JSON.parse(text).data;
|
||||
if (actual) return { actual };
|
||||
} catch (e) {
|
||||
// keine JSON-Antwort
|
||||
}
|
||||
|
||||
// unter 500 steht eine PHP-Meldung vor dem JSON: die Datei ist kaputt, Warten hilft nicht
|
||||
if (response.status < 500) {
|
||||
throw new Error(
|
||||
`getCisConfig antwortet nach dem Profilwechsel mit HTTP ${response.status}: ${text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return { actual: null, reason: `getCisConfig antwortet mit HTTP ${response.status}` };
|
||||
};
|
||||
|
||||
/** Waits until getCisConfig returns the profile. PHP-FPM does not read a modified file until opcache.revalidate_freq has elapsed. */
|
||||
const verifyInstance = async (data) => {
|
||||
const expected = { ...data.config, ...data.flags, ...data.expect };
|
||||
const baseUrl = String(process.env.BASE_URL).replace(/\/+$/, "");
|
||||
const url = `${baseUrl}/index.ci.php/api/frontend/v1/Noten/getCisConfig`;
|
||||
const auth = `Basic ${Buffer.from(`${process.env.NOTEN_USER}:${process.env.NOTEN_PASSWORD}`).toString("base64")}`;
|
||||
const deadline = Date.now() + 60000;
|
||||
|
||||
for (;;) {
|
||||
const { actual, reason } = await readCisConfig(url, auth);
|
||||
let pending = reason;
|
||||
|
||||
if (actual) {
|
||||
const checkable = Object.keys(expected).filter((key) => key in actual);
|
||||
if (!checkable.length) {
|
||||
console.warn("[profile] getCisConfig liefert keinen Schlüssel des Profils. Der Wechsel ist ungeprüft.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviating = checkable.filter((key) => !matches(actual[key], expected[key]));
|
||||
if (!deviating.length) return;
|
||||
|
||||
const lines = deviating.map(
|
||||
(k) => ` ${k}: ${JSON.stringify(actual[k])} statt ${JSON.stringify(expected[k])}`,
|
||||
);
|
||||
pending = `die Instanz zeigt das Profil nicht:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) throw new Error(`Profilwechsel nach 60 Sekunden ungeprüft, ${pending}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
};
|
||||
|
||||
// --- öffentlich ---
|
||||
|
||||
/** Retrieves the active profile. Returns its name or null. */
|
||||
const restore = async () => {
|
||||
if (!sshConfigured()) return null;
|
||||
|
||||
return withSftp(async (sftp) => {
|
||||
const state = await readState(sftp);
|
||||
if (!state || !Object.keys(state.files).length) return null;
|
||||
|
||||
const previous = state.profile;
|
||||
for (const fileKey of Object.keys(FILES)) await restoreFile(sftp, state, fileKey);
|
||||
|
||||
state.profile = null;
|
||||
await writeState(sftp, state);
|
||||
console.log(`[profile] "${previous}" ist zurückgenommen.`);
|
||||
return previous;
|
||||
});
|
||||
};
|
||||
|
||||
/** Sets the instance to a profile. If no name is specified, it uses the defaults. */
|
||||
const apply = async (name) => {
|
||||
if (!name) return restore();
|
||||
|
||||
const data = profileData(name);
|
||||
const root = envValue("REMOTE_ROOT");
|
||||
if (!sshConfigured() || !root) {
|
||||
throw new Error(
|
||||
`Profil "${name}" braucht ${PREFIX}_SSH_HOST, ${PREFIX}_SSH_USER und ${PREFIX}_REMOTE_ROOT in tests/cypress/local/.env.`,
|
||||
);
|
||||
}
|
||||
|
||||
await withSftp(async (sftp) => {
|
||||
if (!(await exists(sftp, BACKUP_DIR))) await sftpCall((cb) => sftp.mkdir(BACKUP_DIR, { mode: 0o700 }, cb));
|
||||
|
||||
const state = (await readState(sftp)) || { files: {} };
|
||||
if (state.root && state.root !== root && Object.keys(state.files).length) {
|
||||
throw new Error(
|
||||
`Auf ${state.root} ist noch "${state.profile}" aktiv. Zuerst: node tests/cypress/local/notenProfiles.js restore`,
|
||||
);
|
||||
}
|
||||
state.root = root;
|
||||
state.profile = name;
|
||||
|
||||
if (Object.keys(data.config).length)
|
||||
await applyFile(sftp, state, "config", (o) => withConfig(o, name, data.config));
|
||||
else await restoreFile(sftp, state, "config");
|
||||
|
||||
if (Object.keys(data.flags).length) await applyFile(sftp, state, "flags", (o) => withFlags(o, data.flags));
|
||||
else await restoreFile(sftp, state, "flags");
|
||||
|
||||
await writeState(sftp, state);
|
||||
});
|
||||
|
||||
await verifyInstance(data);
|
||||
console.log(`[profile] "${name}" ist aktiv.`);
|
||||
return name;
|
||||
};
|
||||
|
||||
const status = () => (sshConfigured() ? withSftp(readState) : Promise.resolve(null));
|
||||
|
||||
module.exports = { apply, restore, status, profileData };
|
||||
@@ -0,0 +1,137 @@
|
||||
LOCAL WORKSTATION - SSH TUNNEL AND CONFIGURATION PROFILES
|
||||
=========================================================
|
||||
These tools are for local workstations only. No file of the suite refers to this folder. The team does
|
||||
not need it to run the tests.
|
||||
|
||||
The tools do two things:
|
||||
|
||||
- They open an SSH tunnel to the test database. pg_hba.conf accepts only the application server, so
|
||||
a workstation cannot connect directly.
|
||||
- They switch the test instance to a configuration profile, run the suite, and put the original
|
||||
configuration back.
|
||||
|
||||
1. Files
|
||||
-----------------
|
||||
|
||||
withTunnel.js Opens the tunnel, starts a command, and closes the tunnel when the command ends.
|
||||
sshTunnel.js The tunnel itself (package ssh2). withTunnel.js and profileSwitch.js use it.
|
||||
notenProfiles.js Command line for the profiles: status, apply, restore, run.
|
||||
profileSwitch.js Writes the profile over SFTP, keeps the backups, checks the instance.
|
||||
eslint.config.js Lint rules of the suite. They enforce the conventions in .claude/rules/.
|
||||
package.json ssh2, ESLint and Prettier. Cypress, dotenv and pg come from the repository root.
|
||||
.env SSH access and the path of the instance (gitignored).
|
||||
|
||||
The profiles are in tests/cypress/profiles/noten.js. That file is part of the suite.
|
||||
|
||||
|
||||
2. Preparation
|
||||
---------------
|
||||
|
||||
Install the packages in this folder:
|
||||
|
||||
cd tests/cypress/local
|
||||
npm install
|
||||
|
||||
Create the credentials file:
|
||||
|
||||
tests/cypress/local/.env.example -> tests/cypress/local/.env
|
||||
|
||||
The tools also read tests/cypress/.env (BASE_URL) and tests/cypress/suites/.env (NOTEN_USER,
|
||||
NOTEN_DB_*). Fill out these two files first. readme_benotungstool.txt explains them.
|
||||
|
||||
- NOTEN_DB_HOST in suites/.env stays the real database host, as the SSH host sees it. Do not write
|
||||
127.0.0.1. The tunnel changes the value only for the command that it starts.
|
||||
- NOTEN_SSH_HOST is the SSH host. It is usually not the web hostname.
|
||||
- Without NOTEN_SSH_KEY, the tools use only the SSH agent. They do not read ~/.ssh themselves.
|
||||
- Use the agent if possible: run ssh-add and keep NOTEN_SSH_KEY empty. On Windows, the service
|
||||
"OpenSSH Authentication Agent" must run.
|
||||
- NOTEN_SSH_PASSPHRASE keeps the passphrase in plain text. Use it only if the agent is not possible.
|
||||
- NOTEN_REMOTE_ROOT is the absolute path of the instance on its host. Only the profiles need it.
|
||||
- Set NOTEN_PROFILE_SSH_* only when the instance runs on a different host than the database tunnel.
|
||||
- The tools need Node 18 or later.
|
||||
|
||||
|
||||
3. SSH tunnel
|
||||
--------------
|
||||
|
||||
Run the commands from the repository root. Put withTunnel.js in front of each command that needs
|
||||
the database:
|
||||
|
||||
node tests/cypress/local/withTunnel.js npm run noten:check
|
||||
node tests/cypress/local/withTunnel.js npm run noten:api
|
||||
node tests/cypress/local/withTunnel.js npm run noten:ui
|
||||
node tests/cypress/local/withTunnel.js npx cypress open
|
||||
|
||||
withTunnel.js opens the tunnel on a free local port. Then it sets NOTEN_DB_HOST=127.0.0.1 and
|
||||
NOTEN_DB_PORT=<port> for the command. The .env files do not change. When the command ends, the tunnel
|
||||
closes. Ctrl+C stops the command first and then the tunnel.
|
||||
|
||||
The same result without this code: open the tunnel in a second terminal, and write fixed values in
|
||||
suites/.env.
|
||||
|
||||
ssh -N -L 15432:<database host>:5432 <user>@<ssh host>
|
||||
|
||||
NOTEN_DB_HOST=127.0.0.1
|
||||
NOTEN_DB_PORT=15432
|
||||
|
||||
|
||||
4. Configuration profiles
|
||||
--------------------------
|
||||
|
||||
The suite tests the configuration it finds. Some rules have a second branch, for example two
|
||||
Antritte instead of three. A profile switches the instance to such a branch.
|
||||
|
||||
node tests/cypress/local/notenProfiles.js status Active profile and all profiles.
|
||||
node tests/cypress/local/notenProfiles.js apply <profile> Switch the instance to a profile.
|
||||
node tests/cypress/local/notenProfiles.js restore Put the original files back.
|
||||
node tests/cypress/local/notenProfiles.js run [api|ui|all] [a,b] One run for each profile.
|
||||
|
||||
|
||||
5. Lint and format
|
||||
-------------------
|
||||
|
||||
Run the commands in this folder. The paths in eslint.config.js start at the repository root, so
|
||||
both scripts change to the root first.
|
||||
|
||||
npm run lint ESLint for tests/cypress and cypress.config.js. Add "-- --fix" to fix.
|
||||
npm run format Prettier for tests/cypress: tabs, 120 columns. It writes the files.
|
||||
|
||||
|
||||
6. Debugging
|
||||
------------------------
|
||||
|
||||
"SSH to <user>@<host> ... failed" or "SSH zu <user>@<host> gescheitert"
|
||||
The SSH host is often not the web hostname. Without NOTEN_SSH_KEY.
|
||||
Run ssh-add, or write the key path in NOTEN_SSH_KEY.
|
||||
|
||||
"SSH key does not exist"
|
||||
The path in NOTEN_SSH_KEY is wrong. A path can start with ~.
|
||||
|
||||
The tunnel is up, but the database connection closes immediately
|
||||
The SSH host cannot reach NOTEN_DB_HOST. If Postgres runs on the SSH host itself, write localhost.
|
||||
|
||||
"Profilwechsel nach 60 Sekunden ungeprüft"
|
||||
The instance at BASE_URL does not show the profile. The message lists each key with the actual and
|
||||
the expected value. Make sure that NOTEN_REMOTE_ROOT is the path of the instance at BASE_URL.
|
||||
|
||||
"getCisConfig antwortet nach dem Profilwechsel mit HTTP ..."
|
||||
HTTP 401: NOTEN_USER or NOTEN_PASSWORD is wrong. Other codes: the changed file contains a PHP
|
||||
error. Run restore, then examine the values of the profile.
|
||||
|
||||
"Auf <path> ist noch "<profile>" aktiv"
|
||||
A profile is still active on a different NOTEN_REMOTE_ROOT. Run restore. It uses the path in
|
||||
state.json.
|
||||
|
||||
"Die Sicherung ... fehlt"
|
||||
The backup in ~/.cypress-noten-profile/ is missing, so the original file is unknown. Deploy the
|
||||
file again. The tool then takes it as the original.
|
||||
|
||||
"... definiert <FLAG> nicht"
|
||||
The profile sets a flag that config/global.config.inc.php does not define. Add the define() line
|
||||
on the instance, or remove the flag from the profile.
|
||||
|
||||
Ctrl+C during notenProfiles.js run
|
||||
The instance stays on the current profile. Run restore.
|
||||
|
||||
Self-signed certificate on the instance
|
||||
Set NODE_TLS_REJECT_UNAUTHORIZED=0 in local/.env.
|
||||
@@ -11,9 +11,6 @@ let server = null;
|
||||
let sshClient = null;
|
||||
let localPort = null;
|
||||
|
||||
/** Local end of the forward, or null. Lets the pool bind without going through the environment. */
|
||||
const tunnelPort = () => localPort;
|
||||
|
||||
const resolveAgent = (agent) => {
|
||||
if (agent && agent !== "true") return agent;
|
||||
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
|
||||
@@ -139,4 +136,4 @@ const ensureTunnel = async (cfg) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { ensureTunnel, closeTunnel, tunnelPort, resolveAuth };
|
||||
module.exports = { ensureTunnel, closeTunnel, resolveAuth };
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Runs a command using the database behind an SSH tunnel. For your own workstation only:
|
||||
* No part of the suite references this folder.
|
||||
*
|
||||
* node tests/cypress/local/withTunnel.js npm run noten:api
|
||||
* node tests/cypress/local/withTunnel.js npm run noten:check
|
||||
* node tests/cypress/local/withTunnel.js npx cypress open
|
||||
*
|
||||
* The tunnel sets the command’s <PREFIX>_DB_HOST and <PREFIX>_DB_PORT to its local endpoint.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { ensureTunnel, closeTunnel } = require("./sshTunnel");
|
||||
|
||||
["local", "suites"].forEach((dir) => require("dotenv").config({ path: path.join(__dirname, "..", dir, ".env") }));
|
||||
|
||||
const PREFIX = process.env.TEST_ENV_PREFIX || "TEST";
|
||||
const env = (key) => process.env[`${PREFIX}_${key}`];
|
||||
|
||||
/** Opens the tunnel and redirects the DB values in process.env to it. Provides the function to close it. */
|
||||
const openDbTunnel = async () => {
|
||||
const tunnel = await ensureTunnel({
|
||||
sshHost: env("SSH_HOST"),
|
||||
sshPort: Number(env("SSH_PORT") || 22),
|
||||
sshUser: env("SSH_USER"),
|
||||
keyPath: env("SSH_KEY"),
|
||||
passphrase: env("SSH_PASSPHRASE"),
|
||||
agent: env("SSH_AGENT"),
|
||||
dbHost: env("DB_HOST"),
|
||||
dbPort: Number(env("DB_PORT") || 5432),
|
||||
});
|
||||
if (!tunnel.tunnelled) throw new Error(`SSH tunnel failed: ${tunnel.reason}`);
|
||||
|
||||
process.env[`${PREFIX}_DB_HOST`] = "127.0.0.1";
|
||||
process.env[`${PREFIX}_DB_PORT`] = String(tunnel.localPort);
|
||||
return closeTunnel;
|
||||
};
|
||||
|
||||
// cmd.exe runs npm and npx only as command-line tools
|
||||
const commandLine = (parts) => parts.map((p) => (/[\s,;=&|<>^]/.test(p) ? `"${p}"` : p)).join(" ");
|
||||
|
||||
const main = async () => {
|
||||
const parts = process.argv.slice(2);
|
||||
if (!parts.length) {
|
||||
console.error("\n node tests/cypress/local/withTunnel.js <command> [arguments]\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const close = await openDbTunnel();
|
||||
|
||||
// Strg+C erreicht auch den Befehl; der Tunnel bleibt offen, bis der Befehl endet
|
||||
process.on("SIGINT", () => {});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(commandLine(parts), { stdio: "inherit", shell: true });
|
||||
const finish = async (code) => {
|
||||
await close();
|
||||
resolve(code);
|
||||
};
|
||||
child.on("error", (error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
finish(1);
|
||||
});
|
||||
child.on("exit", (code) => finish(code === null ? 1 : code));
|
||||
});
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
.then((code) => process.exit(code))
|
||||
.catch((error) => {
|
||||
console.error(`\n${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { openDbTunnel };
|
||||
@@ -1,34 +1,33 @@
|
||||
/**
|
||||
* Konfigurationsprofile der Benotungstool-Suite.
|
||||
* Configuration profiles for the Grading Tool Suite.
|
||||
*
|
||||
* Ein Profil nennt nur die Abweichung vom Stand der Instanz. config ändert application/config/noten.php,
|
||||
* flags ändert die define()-Schalter in config/global.config.inc.php. Nach dem Wechsel prüft die Suite
|
||||
* jeden Schlüssel, den getCisConfig unter demselben Namen liefert. pruefe nennt Werte, die ein Flag nur
|
||||
* mittelbar ändert oder die getCisConfig aufgelöst liefert.
|
||||
* A profile specifies only the deviation from the instance's current state. “config” modifies application/config/noten.php,
|
||||
* “flags” modifies the define() flags in config/global.config.inc.php. After the change, the suite checks
|
||||
* every key that getCisConfig returns under the same name. expect specifies values that a flag only
|
||||
* indirectly changes or that getCisConfig returns after resolution.
|
||||
*
|
||||
* Ein Profil schaltet Zweige ein, die kein anderes Profil prüft. `npm run cy:noten:profile` listet am
|
||||
* Ende die Tests, die in keinem Profil laufen.
|
||||
* A profile enables branches that no other profile checks. A run over all profiles lists, at the
|
||||
* end, the tests that do not run in any profile.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// gilt in jedem Profil. Die Freigabemail bleibt an: die Entwicklungsinstanzen stellen jede Mail in ein
|
||||
// Debug-Postfach zu.
|
||||
basis: {},
|
||||
// applies to every profile
|
||||
base: {},
|
||||
|
||||
profile: {
|
||||
standard: {
|
||||
beschreibung: "die Konfiguration der Instanz",
|
||||
profiles: {
|
||||
default: {
|
||||
description: "die Konfiguration der Instanz",
|
||||
},
|
||||
|
||||
auslieferung: {
|
||||
beschreibung: "die Schalter aus global.config-default.inc.php: TERMIN3 an",
|
||||
shipped: {
|
||||
description: "die Schalter aus global.config-default.inc.php: TERMIN3 an",
|
||||
flags: { CIS_GESAMTNOTE_PRUEFUNG_TERMIN3: true },
|
||||
// 1 + TERMIN2 + TERMIN3 + KOMMPRUEF, solange CIS_GESAMTNOTE_MAX_ANTRITTE fehlt
|
||||
pruefe: { CIS_GESAMTNOTE_MAX_ANTRITTE: 4 },
|
||||
expect: { CIS_GESAMTNOTE_MAX_ANTRITTE: 4 },
|
||||
},
|
||||
|
||||
"ohne-erstantritt": {
|
||||
beschreibung: "kein Pfad legt Antritt 1 an, die Freigabe verlangt kein Passwort",
|
||||
"no-erstantritt": {
|
||||
description: "kein Pfad legt Antritt 1 an, die Freigabe verlangt kein Passwort",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME: false,
|
||||
CIS_GESAMTNOTE_FREIGABE_PASSWORT: false,
|
||||
@@ -36,12 +35,12 @@ module.exports = {
|
||||
},
|
||||
|
||||
final: {
|
||||
beschreibung: "eine freigegebene Note ist endgültig",
|
||||
description: "eine freigegebene Note ist endgültig",
|
||||
config: { CIS_GESAMTNOTE_FREIGABE_FINAL: true },
|
||||
},
|
||||
|
||||
"zwei-antritte": {
|
||||
beschreibung: "zwei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
|
||||
"two-antritte": {
|
||||
description: "zwei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_MAX_ANTRITTE: 2,
|
||||
CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false,
|
||||
@@ -49,19 +48,19 @@ module.exports = {
|
||||
},
|
||||
|
||||
"kommpruef-stv": {
|
||||
beschreibung: "drei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
|
||||
description: "drei Antritte, die kommissionelle Prüfung trägt die Studierendenverwaltung ein",
|
||||
config: { CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF: false },
|
||||
},
|
||||
|
||||
"ohne-kommission": {
|
||||
beschreibung: "kein Antritt ist kommissionell",
|
||||
"no-kommission": {
|
||||
description: "kein Antritt ist kommissionell",
|
||||
config: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: 0 },
|
||||
// getCisConfig löst 0 zu null auf
|
||||
pruefe: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: null },
|
||||
expect: { CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT: null },
|
||||
},
|
||||
|
||||
rollen: {
|
||||
beschreibung: "die Assistenz darf weder freigeben noch die kommissionelle Prüfung anlegen",
|
||||
description: "die Assistenz darf weder freigeben noch die kommissionelle Prüfung anlegen",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_ROLLENMATRIX: {
|
||||
"lehre/benotungstool": ["vorschlag", "pruefung", "kommpruef", "freigabe", "import"],
|
||||
@@ -70,43 +69,43 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
|
||||
// Die Specs legen Antritte im Abstand von 30 Tagen an, mit einem entschuldigten Termin dazwischen
|
||||
// 60 Tage. Beide Grenzen lassen diese Abstände zu.
|
||||
abstand: {
|
||||
beschreibung: "Wartefrist 14 Tage und Höchstfrist 400 Tage zwischen zwei Antritten",
|
||||
// The specifications call for appearances at 30-day intervals, with one excused appointment in between
|
||||
// 60 days. Both limits allow for these intervals.
|
||||
"antritt-gap": {
|
||||
description: "Wartefrist 14 Tage und Höchstfrist 400 Tage zwischen zwei Antritten",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_ANTRITT_MIN_ABSTAND_TAGE: 14,
|
||||
CIS_GESAMTNOTE_ANTRITT_MAX_ABSTAND_TAGE: 400,
|
||||
},
|
||||
},
|
||||
|
||||
verbesserung: {
|
||||
beschreibung: "Antritt nach einer positiven Note, die bessere Note bleibt LV-Note",
|
||||
notenverbesserung: {
|
||||
description: "Antritt nach einer positiven Note, die bessere Note bleibt LV-Note",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTENVERBESSERUNG: true,
|
||||
CIS_GESAMTNOTE_VERBESSERUNG_BESSERE_GEWINNT: true,
|
||||
},
|
||||
},
|
||||
|
||||
// nicht mit abstand: am selben Tag beträgt der Abstand 0 Tage
|
||||
"gleicher-tag": {
|
||||
beschreibung: "ein neuer Termin darf am Tag eines bestehenden liegen",
|
||||
// not including the start gap: on the same day, the gap is 0 days
|
||||
"same-day": {
|
||||
description: "ein neuer Termin darf am Tag eines bestehenden liegen",
|
||||
config: { CIS_GESAMTNOTE_TERMIN_GLEICHER_TAG: true },
|
||||
},
|
||||
|
||||
"frist-ausnahme": {
|
||||
beschreibung: "die Lehrenden dürfen nach der Eingabefrist eintragen",
|
||||
"frist-exception": {
|
||||
description: "die Lehrenden dürfen nach der Eingabefrist eintragen",
|
||||
config: { CIS_GESAMTNOTE_FRIST_AUSNAHME: ["lehre/benotungstool"] },
|
||||
pruefe: { CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT: true },
|
||||
expect: { CIS_GESAMTNOTE_FRIST_AUSNAHME_GILT: true },
|
||||
},
|
||||
|
||||
"frist-nur-datum": {
|
||||
beschreibung: "nur das Prüfungsdatum ist an die Frist gebunden, die Eingabe nicht",
|
||||
"frist-date-only": {
|
||||
description: "nur das Prüfungsdatum ist an die Frist gebunden, die Eingabe nicht",
|
||||
config: { CIS_GESAMTNOTE_FRIST_EINGABE: false },
|
||||
},
|
||||
|
||||
offen: {
|
||||
beschreibung:
|
||||
open: {
|
||||
description:
|
||||
"frühere Noten änderbar, künftiges Benotungsdatum, keine Datumsfrist, ein Termin hebt die Freigabe nicht auf",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTE_SPERRE_BEI_SPAETEREM_TERMIN: false,
|
||||
@@ -117,7 +116,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
notenimport: {
|
||||
beschreibung: "der Notenimport ohne Datum, mit dem Kürzel aus tbl_note.anmerkung",
|
||||
description: "der Notenimport ohne Datum, mit dem Kürzel aus tbl_note.anmerkung",
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTENIMPORT: true,
|
||||
CIS_GESAMTNOTE_IMPORT_NOTENKUERZEL: true,
|
||||
@@ -125,13 +124,13 @@ module.exports = {
|
||||
},
|
||||
|
||||
punkte: {
|
||||
beschreibung: "Punktemodus",
|
||||
description: "Punktemodus",
|
||||
flags: { CIS_GESAMTNOTE_PUNKTE: true },
|
||||
},
|
||||
|
||||
// BESSERE_GEWINNT bleibt aus: eine schlechtere Verbesserung schreibt die letzte Note
|
||||
"punkte-verbesserung": {
|
||||
beschreibung: "Punktemodus mit Notenverbesserung und Notenimport",
|
||||
// “BESSERE_GEWINNT” is omitted: a lower improvement results in the final grade
|
||||
"punkte-notenverbesserung": {
|
||||
description: "Punktemodus mit Notenverbesserung und Notenimport",
|
||||
flags: { CIS_GESAMTNOTE_PUNKTE: true },
|
||||
config: {
|
||||
CIS_GESAMTNOTE_NOTENVERBESSERUNG: true,
|
||||
|
||||
@@ -2,23 +2,27 @@
|
||||
* Wires the Gesamtnoteneingabe suite into cypress.config.js: its cy.task handlers, its run hooks and
|
||||
* the Cypress.env() values its specs read.
|
||||
*
|
||||
* The split is the point -- nothing NOTEN_* belongs in the config, and nothing generic belongs
|
||||
* here. A second suite adds its own file next to this one and the config changes by one require.
|
||||
* The suite's keys come from tests/cypress/suites/.env or from the environment.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
|
||||
// before the task files: db.js reads TEST_ENV_PREFIX when it loads
|
||||
require("dotenv").config({ path: path.join(__dirname, ".env") });
|
||||
|
||||
const { registerNotenDbTasks } = require("../tasks/notenDb");
|
||||
const profil = require("../tasks/notenProfil");
|
||||
const { closeDb } = require("../tasks/db");
|
||||
|
||||
// cy.request sends one request after the other; the lock test needs two at the same time.
|
||||
// Basic auth without a session cookie: PHP serializes two requests of one session on the session lock,
|
||||
// and the test would no longer reach the advisory lock.
|
||||
const parallelPost = ({ path, bodies }) => {
|
||||
const parallelPost = ({ path: apiPath, bodies }) => {
|
||||
const base = String(process.env.BASE_URL).replace(/\/+$/, "");
|
||||
const auth = "Basic " + Buffer.from(`${process.env.USER_NAME}:${process.env.USER_PASSWORD}`).toString("base64");
|
||||
const auth = "Basic " + Buffer.from(`${process.env.NOTEN_USER}:${process.env.NOTEN_PASSWORD}`).toString("base64");
|
||||
|
||||
return Promise.all(
|
||||
bodies.map((body) =>
|
||||
fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${path}`, {
|
||||
fetch(`${base}/index.ci.php/api/frontend/v1/Noten/${apiPath}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
@@ -34,35 +38,38 @@ const parallelPost = ({ path, bodies }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const env = () => ({
|
||||
// the lecturer the suite works as; separate from USER_NAME, which belongs to every suite
|
||||
NOTEN_USER: process.env.NOTEN_USER || null,
|
||||
NOTEN_PASSWORD: process.env.NOTEN_PASSWORD || null,
|
||||
|
||||
// Optional pins; semester and LV are discovered at runtime when unset.
|
||||
NOTEN_SEM: process.env.NOTEN_SEM || null,
|
||||
NOTEN_LV_ID: process.env.NOTEN_LV_ID || null,
|
||||
|
||||
// LDAP password for the Freigabe specs; empty = NOTEN_PASSWORD
|
||||
NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null,
|
||||
|
||||
// Access-control specs need real per-role credentials: this API has no impersonation.
|
||||
NOTEN_TEACHER_USER: process.env.NOTEN_TEACHER_USER || null,
|
||||
NOTEN_TEACHER_PASSWORD: process.env.NOTEN_TEACHER_PASSWORD || null,
|
||||
NOTEN_FOREIGN_LV_ID: process.env.NOTEN_FOREIGN_LV_ID || null,
|
||||
|
||||
// assistant account for the role matrix: carries only lehre/benotungstool_assistenz
|
||||
NOTEN_ASSISTENZ_USER: process.env.NOTEN_ASSISTENZ_USER || null,
|
||||
NOTEN_ASSISTENZ_PASSWORD: process.env.NOTEN_ASSISTENZ_PASSWORD || null,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
registerTasks: (on) => {
|
||||
setupNodeEvents: (on, config) => {
|
||||
registerNotenDbTasks(on);
|
||||
on("task", { "noten:http:parallel": parallelPost });
|
||||
},
|
||||
|
||||
// NOTEN_PROFIL switches the instance before the run. Without it the run restores the originals, so an
|
||||
// aborted run does not leave the instance on a profile.
|
||||
beforeRun: (config) => profil.anwenden(config.env.NOTEN_PROFIL || null),
|
||||
afterRun: (config) => (config.env.NOTEN_PROFIL ? profil.wiederherstellen() : null),
|
||||
// the pool closes after the run, so the database keeps no idle connections of it
|
||||
on("after:run", () => closeDb());
|
||||
|
||||
env: {
|
||||
// Optional pins; semester and LV are discovered at runtime when unset.
|
||||
NOTEN_SEM: process.env.NOTEN_SEM || null,
|
||||
NOTEN_LV_ID: process.env.NOTEN_LV_ID || null,
|
||||
|
||||
// configuration profile from tests/cypress/profiles/noten.js; empty = the instance as deployed
|
||||
NOTEN_PROFIL: process.env.NOTEN_PROFIL || null,
|
||||
|
||||
// LDAP password for the Freigabe specs; empty = USER_PASSWORD
|
||||
NOTEN_FREIGABE_PASSWORD: process.env.NOTEN_FREIGABE_PASSWORD || null,
|
||||
|
||||
// Access-control specs need real per-role credentials: this API has no impersonation.
|
||||
NOTEN_TEACHER_USER: process.env.NOTEN_TEACHER_USER || null,
|
||||
NOTEN_TEACHER_PASSWORD: process.env.NOTEN_TEACHER_PASSWORD || null,
|
||||
NOTEN_FOREIGN_LV_ID: process.env.NOTEN_FOREIGN_LV_ID || null,
|
||||
|
||||
// assistant account for the role matrix: carries only lehre/benotungstool_assistenz
|
||||
NOTEN_ASSISTENZ_USER: process.env.NOTEN_ASSISTENZ_USER || null,
|
||||
NOTEN_ASSISTENZ_PASSWORD: process.env.NOTEN_ASSISTENZ_PASSWORD || null,
|
||||
// a value from --env wins over the suite's .env
|
||||
config.env = { ...env(), ...config.env };
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,135 +1,87 @@
|
||||
BENOTUNGSTOOL - TESTSUITE
|
||||
=========================
|
||||
The suite deletes and writes notes to the database. Run only on a test instance.
|
||||
|
||||
WARNUNG: Die Suite löscht und schreibt Noten in der Datenbank. Starte sie nur gegen eine
|
||||
Testinstanz.
|
||||
|
||||
|
||||
1. WAS HIER LIEGT
|
||||
1. Files
|
||||
-----------------
|
||||
|
||||
e2e/specs/unit/ Regeln als reine Funktion. Kein Server, keine Datenbank.
|
||||
e2e/specs/api/noten/ Die API. Ein Test ruft einen Endpunkt auf und prüft die Antwort.
|
||||
e2e/specs/ui/ Die Oberfläche im Browser.
|
||||
e2e/specs/unit/ Rules as pure functions.
|
||||
e2e/specs/api/noten/ The API, called by tests to query an endpoint and verify the response.
|
||||
e2e/specs/ui/ The user interface in the browser.
|
||||
|
||||
support/api/ Die API-Aufrufe an einer Stelle. Spiegelt public/js/api/factory/noten.js.
|
||||
support/pages/ Die Oberfläche an einer Stelle. Selektoren und Klickwege.
|
||||
support/helpers/ Testdaten, Fehlermeldungen, Fixture-Reset.
|
||||
tasks/ Datenbankverbindung und SSH-Tunnel. Läuft in Node, nicht im Browser.
|
||||
suites/noten.js Verbindet die Suite mit cypress.config.js.
|
||||
tools/dbCheck.js Prüft die Fixture ohne HTTP. Gutes erstes Kommando bei Problemen.
|
||||
support/api/ API calls, which mirror public/js/api/factory/noten.js.
|
||||
support/pages/ The user interface with selectors and click paths.
|
||||
support/helpers/ Test data, error messages, fixture reset.
|
||||
tasks/ Database connection in Node, not in the browser.
|
||||
suites/noten.js Links the suite to cypress.config.js.
|
||||
suites/.env The suite’s environment variables.
|
||||
tools/dbCheck.js Checks the fixture without HTTP. A good first command to run if problems arise.
|
||||
|
||||
Die Konfiguration steht in cypress.config.js. Die Zugangsdaten stehen in
|
||||
tests/cypress/.env.
|
||||
The configuration is located in cypress.config.js. The credentials are stored in two files:
|
||||
tests/cypress/.env applies to all suites, while tests/cypress/suites/.env applies only to this suite.
|
||||
|
||||
|
||||
2. VORBEREITUNG
|
||||
2. Preparation
|
||||
---------------
|
||||
|
||||
Einmalig:
|
||||
Create login credentials in actual .env files:
|
||||
|
||||
npm install
|
||||
tests/cypress/.env.example -> tests/cypress/.env
|
||||
tests/cypress/suites/.env.example -> tests/cypress/suites/.env
|
||||
|
||||
Dann die Zugangsdaten anlegen:
|
||||
Fill out both files. The comments within them explain each value.
|
||||
|
||||
cp tests/cypress/.env.example tests/cypress/.env
|
||||
- NOTEN_USER is the INSTRUCTOR, not an administrator. An administrator usually does not teach any courses,
|
||||
so the suite will not find any courses.
|
||||
- The suite requires a direct connection to the database (NOTEN_DB_*).
|
||||
|
||||
Fülle die Datei aus. Die Kommentare darin erklären jeden Wert. Drei Punkte sind wichtig:
|
||||
|
||||
- .env darf nie in Git landen.
|
||||
- USER_NAME ist der LEKTOR, nicht ein Administrator. Ein Administrator unterrichtet meist nichts,
|
||||
dann findet die Suite keine Lehrveranstaltung.
|
||||
- Von einer Arbeitsstation aus brauchst du den SSH-Tunnel. Die Datenbank nimmt nur den
|
||||
Applikationsserver an. Setze NOTEN_SSH_TUNNEL=true und die drei NOTEN_SSH_*-Werte.
|
||||
|
||||
Beide Seiten müssen auf dieselbe Datenbank zeigen: die Webinstanz (config/system.config.inc.php,
|
||||
DB_NAME) und die Suite (NOTEN_DB_NAME in .env). Sonst seedet die Suite die eine Datenbank und prüft
|
||||
gegen die andere.
|
||||
Both sides must point to the same database: the web instance (config/system.config.inc.php,
|
||||
DB_NAME) and the suite (NOTEN_DB_NAME in suites/.env). Otherwise, the suite will seed one database and
|
||||
check against the other.
|
||||
|
||||
|
||||
3. STARTEN
|
||||
3. Start
|
||||
----------
|
||||
|
||||
npm run noten:check Fixture prüfen.
|
||||
npm run cy:noten:api Unit- und API-Tests.
|
||||
npm run cy:noten:ui Oberfläche in Chrome.
|
||||
npm run cy:open Cypress interaktiv.
|
||||
npm run noten:check Check fixture.
|
||||
npm run noten:api Unit and API tests.
|
||||
npm run noten:ui User interface in Chrome.
|
||||
npx cypress open Cypress interactive.
|
||||
|
||||
Zwei weitere Skripte prüfen den Punktemodus:
|
||||
Put more Cypress options after "--". A second --spec replaces the default:
|
||||
|
||||
npm run cy:noten:punkte-on erwartet CIS_GESAMTNOTE_PUNKTE = true
|
||||
npm run cy:noten:punkte-off erwartet CIS_GESAMTNOTE_PUNKTE = false
|
||||
npm run noten:api -- --spec tests/cypress/e2e/specs/api/noten/noten.frist.cy.js
|
||||
npm run noten:ui -- --headed --no-exit
|
||||
npm run noten:api -- --env NOTEN_PUNKTE_MODE=on
|
||||
|
||||
Passt die Instanz nicht zur Erwartung, bricht der Lauf sofort ab.
|
||||
|
||||
Die Konfiguration der Instanz wechselt über Profile (tests/cypress/profiles/noten.js). Ein Profil
|
||||
schreibt application/config/noten.php und die define()-Schalter in config/global.config.inc.php auf
|
||||
der Instanz um. Nach dem Lauf stellt die Suite beide Dateien wieder her. Dafür braucht .env
|
||||
zusätzlich NOTEN_REMOTE_ROOT.
|
||||
|
||||
npm run cy:noten:profile jedes Profil, Unit und API, danach der Bericht
|
||||
npm run cy:noten:profile -- ui final nur die Oberfläche, nur das Profil final
|
||||
npm run cy:noten:profile -- alle a,b alles, nur die Profile a und b
|
||||
npm run noten:profil -- status das aktive Profil und die Liste der Profile
|
||||
npm run noten:profil -- anwenden final ein Profil einschalten, zum Beispiel vor cy:open
|
||||
npm run noten:profil -- wiederherstellen die Originale zurück
|
||||
|
||||
Jedes Profil schaltet die Freigabemail aus. Nur dann laufen die Freigabetests.
|
||||
|
||||
Ein Lauf ohne Profil stellt zuerst die Originale her. So bleibt die Instanz nach einem abgebrochenen
|
||||
Lauf nicht auf einem Profil stehen.
|
||||
tests/cypress/profiles/noten.js describes the configurations in which the suite should run. A
|
||||
profile specifies the values for application/config/noten.php and the define() switches in
|
||||
config/global.config.inc.php. The suite tests the configuration it finds. Switching
|
||||
the instance is not part of the suite.
|
||||
|
||||
|
||||
4. DAS ERGEBNIS
|
||||
---------------------
|
||||
|
||||
Am Ende steht eine Tabelle mit vier Spalten.
|
||||
|
||||
Passing Der Test lief und war erfolgreich.
|
||||
Failing Der Test lief und fand einen Fehler. Nur das ist ein Problem.
|
||||
Pending Der Test hat sich selbst übersprungen. Das ist kein Fehler.
|
||||
Skipped Ein Test lief nicht, weil vorher etwas abgebrochen ist.
|
||||
|
||||
Pending ist normal. Ein Test überspringt sich, wenn seine Voraussetzung fehlt. Beispiele: der
|
||||
Punktemodus ist aus, die Frist ist nicht aktiv, die Freigabemail ist nicht erlaubt. Den Grund
|
||||
schreibt der Test ins Protokoll.
|
||||
|
||||
Ein Test, der in keinem Profil läuft, ist dagegen eine Lücke. Der Bericht von cy:noten:profile
|
||||
listet diese Tests am Ende. Ergänze für jeden davon ein Profil.
|
||||
|
||||
Der aktuelle Sollzustand: API und Unit 84 Tests, 69 grün, 15 pending. UI 30 Tests, 18 grün,
|
||||
12 pending. Kein roter Test.
|
||||
|
||||
|
||||
5. WENN ETWAS SCHIEFGEHT
|
||||
4. Debugging
|
||||
------------------------
|
||||
|
||||
401 bei allen Tests
|
||||
Die Webinstanz und .env zeigen auf verschiedene Datenbanken. Siehe Abschnitt 2.
|
||||
401 in all tests
|
||||
The web instance and suites/.env likely point to different databases.
|
||||
|
||||
"Fixture reset unavailable"
|
||||
Die Datenbankverbindung fehlt. Prüfe den SSH-Tunnel. Einen Schlüssel mit einem Namen ausserhalb
|
||||
der OpenSSH-Standardnamen probiert SSH nie automatisch. Trage ihn in NOTEN_SSH_KEY ein.
|
||||
“Fixture reset unavailable”
|
||||
The database connection is missing. Check NOTEN_DB_* in tests/cypress/suites/.env. The pg_hba.conf file for the
|
||||
database must allow access from the machine running Cypress.
|
||||
|
||||
Ein Test erwartet eine Fehlermeldung und bekommt eine andere
|
||||
Die Phrase fehlt in der Datenbank. Lass system/phrasesupdate.php auf dem Server laufen.
|
||||
A test expects one error message but receives a different one
|
||||
The phrase is missing from the database -> run system/phrasesupdate.php on the server.
|
||||
|
||||
Die Instanz steht nach einem Absturz noch auf einem Profil
|
||||
npm run noten:profil -- wiederherstellen
|
||||
The next run won’t start
|
||||
A Cypress process is still hanging. Terminate it: Stop-Process -Name Cypress -Force
|
||||
|
||||
Der nächste Lauf startet nicht
|
||||
Ein Cypress-Prozess hängt noch. Beende ihn: Stop-Process -Name Cypress -Force
|
||||
|
||||
Ein Screenshot zeigt eine leere Seite
|
||||
Bei API-Tests ist das normal. Diese Tests öffnen keine Seite.
|
||||
|
||||
|
||||
6. KONVENTIONEN
|
||||
5. Conventions
|
||||
---------------
|
||||
|
||||
- Keine festen Ids im Test. Die Noten kommen aus getNoten, die Regelwerte aus getCisConfig. Eine
|
||||
andere Installation hat andere Zahlen.
|
||||
- Warte nach jeder schreibenden Aktion auf den Request, nicht auf die Oberfläche.
|
||||
- Sprich die Oberfläche über data-cy an, nie über eine CSS-Klasse.
|
||||
- Setze den Zustand vor dem Test über givenBaseline oder resetNotenState. Verlass dich nie auf den
|
||||
Zustand, den ein Test davor hinterlassen hat.
|
||||
- No fixed IDs in the test. The grades come from `getNoten`, the rule values from `getCisConfig`. A
|
||||
different installation will have different primary keys.
|
||||
- After every write operation, wait for the request, not for the UI.
|
||||
- Access the UI via `data-cy`, not via a CSS class.
|
||||
- Set the state before the test using `givenBaseline` or `resetNotenState`. Never rely on the
|
||||
state left behind by a previous test.
|
||||
|
||||
@@ -7,19 +7,25 @@ const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
|
||||
// One session for the whole run. Without a session cookie the server checks each Basic-auth request
|
||||
// against LDAP, and many LDAP binds can block all PHP-FPM workers of the instance. cy.login creates the
|
||||
// session once (cy.session, cacheAcrossSpecs) and restores it before each test of every importing spec.
|
||||
beforeEach(() => cy.login());
|
||||
|
||||
const authOptions = () => ({
|
||||
username: Cypress.env("adminusername"),
|
||||
password: Cypress.env("adminpassword"),
|
||||
beforeEach(() => {
|
||||
const { username, password } = notenAuth();
|
||||
cy.login(username, password);
|
||||
});
|
||||
|
||||
/** The lecturer the suite works as. No fallback to USER_NAME: that user belongs to every suite. */
|
||||
export const notenAuth = () => {
|
||||
const username = Cypress.env("NOTEN_USER");
|
||||
const password = Cypress.env("NOTEN_PASSWORD");
|
||||
if (!username || !password) throw new Error("NOTEN_USER / NOTEN_PASSWORD missing in tests/cypress/suites/.env");
|
||||
return { username, password };
|
||||
};
|
||||
|
||||
// Basic auth stays on every call as the fallback; with a session cookie the server uses the cookie.
|
||||
const apiGet = (path, qs) =>
|
||||
cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: authOptions(), failOnStatusCode: false });
|
||||
cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: notenAuth(), failOnStatusCode: false });
|
||||
|
||||
const apiPost = (path, body) =>
|
||||
cy.request({ method: "POST", url: `${NOTEN_API}/${path}`, body, auth: authOptions(), failOnStatusCode: false });
|
||||
cy.request({ method: "POST", url: `${NOTEN_API}/${path}`, body, auth: notenAuth(), failOnStatusCode: false });
|
||||
|
||||
export const notenApi = {
|
||||
getCisConfig: () => apiGet("getCisConfig"),
|
||||
@@ -71,18 +77,18 @@ export const notenApi = {
|
||||
}),
|
||||
|
||||
/**
|
||||
* LDAP-password gated. data -> [{uid, freigabedatum, benotungsdatum}]
|
||||
* LDAP password-protected data -> [{uid, approval_date, grading_date}]
|
||||
*
|
||||
* Zuerst eine Sitzung: AuthLDAPLib lädt ldap.php mit require_once. Prüft die Basic-Anmeldung desselben
|
||||
* Requests schon LDAP, sieht die Passwortprüfung der Freigabe keine Konfiguration und meldet
|
||||
* "Falsches Passwort". Mit dem Sitzungscookie prüft die Anmeldung kein LDAP.
|
||||
* First, a session: AuthLDAPLib loads ldap.php using require_once. It checks the Basic authentication for the same
|
||||
* request against LDAP; if the password check for the release finds no configuration, it reports
|
||||
* “Incorrect password.” With the session cookie, the login does not check against LDAP.
|
||||
*/
|
||||
saveStudentenNoten: (password, noten, lv_id, sem_kurzbz) =>
|
||||
apiGet("getCisConfig").then(() => apiPost("saveStudentenNoten", { password, noten, lv_id, sem_kurzbz })),
|
||||
|
||||
getNoteByPunkte: (punkte, lv_id, sem_kurzbz) => apiPost("getNoteByPunkte", { punkte, lv_id, sem_kurzbz }),
|
||||
|
||||
// Bulk-Pfade antworten 200 und melden Fehler je Zeile in data[uid]
|
||||
// Bulk paths return a 200 response and report errors on a per-row basis in data[uid]
|
||||
saveNotenvorschlagBulk: (lv_id, sem_kurzbz, noten) =>
|
||||
apiPost("saveNotenvorschlagBulk", { lv_id, sem_kurzbz, noten }),
|
||||
|
||||
@@ -98,15 +104,16 @@ export const notenApi = {
|
||||
|
||||
export const pruefungenOf = (data, uid) => (data[1] || []).filter((p) => p.student_uid === uid);
|
||||
|
||||
/** Antritte in Verlaufsreihenfolge. Specs prüfen position/zaehlt/antritt_nr/terminal, nie den Typ. */
|
||||
/** Antritte in chronological order. Check the specs for position/count/start_no/terminal, never the type. */
|
||||
export const attemptsOf = (data, uid) =>
|
||||
[...pruefungenOf(data, uid)].sort((a, b) => Number(a.position) - Number(b.position));
|
||||
|
||||
/** Only the attempts that consume one - excused / "noch nicht eingetragen" / nicht beurteilt do not. */
|
||||
/** Only the attempts that consume one - excused / "noch nicht eingetragen" do not. */
|
||||
export const countingAttemptsOf = (data, uid) => attemptsOf(data, uid).filter((p) => p.zaehlt);
|
||||
|
||||
/** Legacy projection written for old reports. Asserted in exactly one spec, never used as a rule. */
|
||||
export const pruefungenOfTyp = (data, uid, typ) => pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === typ);
|
||||
export const pruefungenOfType = (data, uid, type) =>
|
||||
pruefungenOf(data, uid).filter((p) => p.pruefungstyp_kurzbz === type);
|
||||
|
||||
export const gradesOf = (data, uid) => (data[3] || {})[uid];
|
||||
|
||||
|
||||
@@ -25,9 +25,8 @@ Cypress.Commands.add("checkPageResources", (url) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Session-Login für die UI-Specs.
|
||||
* cy.session hält es über Specs hinweg, damit nicht jeder Test neu anmeldet.
|
||||
* Ohne Argumente meldet sich der Suite-Benutzer an.
|
||||
* Session-Login. cy.session hält es über Specs hinweg, damit nicht jeder Test neu anmeldet.
|
||||
* Ohne Argumente meldet sich USER_NAME an.
|
||||
*/
|
||||
Cypress.Commands.add("login", (username, password) => {
|
||||
const { adminusername, adminpassword } = Cypress.env();
|
||||
@@ -35,7 +34,7 @@ Cypress.Commands.add("login", (username, password) => {
|
||||
const probe = () =>
|
||||
cy.request({ url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", auth }).its("status").should("eq", 200);
|
||||
|
||||
cy.session(["benotungstool-login", auth.username], probe, {
|
||||
cy.session(["login", auth.username], probe, {
|
||||
cacheAcrossSpecs: true,
|
||||
validate: probe,
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* Das Konto der Assistenz: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD.
|
||||
* The assistant's account: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD.
|
||||
*
|
||||
* getByStgs liefert einen Studiengang nur in einem Semester mit Studienplan. Die Helfer suchen deshalb
|
||||
* vom Semester der Suite rückwärts. Vor dem ersten Aufruf braucht der Test cy.clearAllCookies(), sonst
|
||||
* läuft der Request mit dem Cookie des Suite-Benutzers.
|
||||
* getByStgs returns a degree program only for a single semester with a curriculum. The assistants therefore
|
||||
* search backward from the semester of the suite. Before the first call, the test requires cy.clearAllCookies(); otherwise,
|
||||
* the request will run using the suite user’s cookie.
|
||||
*/
|
||||
|
||||
import { expectNotenSuccess } from "./notenErrors";
|
||||
import { skipWenn } from "./notenConfig";
|
||||
import { skipIf } from "./notenConfig";
|
||||
|
||||
const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten";
|
||||
const SEMESTER_SUCHTIEFE = 6;
|
||||
const SEMESTER_SEARCH_DEPTH = 6;
|
||||
|
||||
export const assistenzConfigured = () =>
|
||||
Boolean(Cypress.env("NOTEN_ASSISTENZ_USER") && Cypress.env("NOTEN_ASSISTENZ_PASSWORD"));
|
||||
|
||||
/** Skip, wenn kein Konto der Assistenz konfiguriert ist. */
|
||||
export const requireAssistenz = (testContext) =>
|
||||
skipWenn(
|
||||
skipIf(
|
||||
testContext,
|
||||
!assistenzConfigured(),
|
||||
"Übersprungen: NOTEN_ASSISTENZ_USER / NOTEN_ASSISTENZ_PASSWORD fehlen.",
|
||||
@@ -28,72 +28,72 @@ export const assistenzAuth = () => ({
|
||||
password: Cypress.env("NOTEN_ASSISTENZ_PASSWORD"),
|
||||
});
|
||||
|
||||
const getAlsAssistenz = (path, qs) =>
|
||||
const getAsAssistenz = (path, qs) =>
|
||||
cy.request({ method: "GET", url: `${NOTEN_API}/${path}`, qs, auth: assistenzAuth(), failOnStatusCode: false });
|
||||
|
||||
/** WS2026 -> [WS2026, SS2026, WS2025, ...] */
|
||||
const semesterAbwaerts = (sem) => {
|
||||
const liste = [sem];
|
||||
let typ = sem.slice(0, 2);
|
||||
let jahr = Number(sem.slice(2, 6));
|
||||
const semestersBackwards = (sem) => {
|
||||
const semesterList = [sem];
|
||||
let type = sem.slice(0, 2);
|
||||
let year = Number(sem.slice(2, 6));
|
||||
|
||||
while (liste.length < SEMESTER_SUCHTIEFE) {
|
||||
if (typ === "WS") {
|
||||
typ = "SS";
|
||||
while (semesterList.length < SEMESTER_SEARCH_DEPTH) {
|
||||
if (type === "WS") {
|
||||
type = "SS";
|
||||
} else {
|
||||
typ = "WS";
|
||||
jahr -= 1;
|
||||
type = "WS";
|
||||
year -= 1;
|
||||
}
|
||||
liste.push(`${typ}${jahr}`);
|
||||
semesterList.push(`${type}${year}`);
|
||||
}
|
||||
return liste;
|
||||
return semesterList;
|
||||
};
|
||||
|
||||
/** -> { sem, data } für das neueste Semester, in dem die Assistenz Studiengänge sieht, sonst null. */
|
||||
export const assistenzKontext = (startSem) => {
|
||||
const liste = semesterAbwaerts(startSem);
|
||||
export const assistenzContext = (startSem) => {
|
||||
const semesterList = semestersBackwards(startSem);
|
||||
|
||||
const probiere = (i) =>
|
||||
i >= liste.length
|
||||
const trySemester = (i) =>
|
||||
i >= semesterList.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: getAlsAssistenz("getBenotungstoolContext", { sem_kurzbz: liste[i] }).then((response) => {
|
||||
const data = expectNotenSuccess(response, `Kontext der Assistenz in ${liste[i]}`);
|
||||
return data.studiengaenge.length ? { sem: liste[i], data } : probiere(i + 1);
|
||||
: getAsAssistenz("getBenotungstoolContext", { sem_kurzbz: semesterList[i] }).then((response) => {
|
||||
const data = expectNotenSuccess(response, `Kontext der Assistenz in ${semesterList[i]}`);
|
||||
return data.studiengaenge.length ? { sem: semesterList[i], data } : trySemester(i + 1);
|
||||
});
|
||||
|
||||
return probiere(0);
|
||||
return trySemester(0);
|
||||
};
|
||||
|
||||
/** -> { sem, lvId, lehreinheiten } für eine LV der Assistenz mit Lehreinheiten in der Datenbank, sonst null. */
|
||||
export const assistenzLvMitLehreinheiten = (startSem) =>
|
||||
assistenzKontext(startSem).then((kontext) => {
|
||||
if (!kontext) return null;
|
||||
export const assistenzLvWithLehreinheiten = (startSem) =>
|
||||
assistenzContext(startSem).then((result) => {
|
||||
if (!result) return null;
|
||||
|
||||
const { sem } = kontext;
|
||||
const studiengaenge = kontext.data.studiengaenge;
|
||||
const { sem } = result;
|
||||
const studiengaenge = result.data.studiengaenge;
|
||||
|
||||
const ersteLvMitLehreinheiten = (lvs, j) =>
|
||||
const firstLvWithLehreinheiten = (lvs, j) =>
|
||||
j >= lvs.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: cy
|
||||
.task("noten:db:lehreinheitenDerLv", { lvId: lvs[j].lehrveranstaltung_id, semKurzbz: sem })
|
||||
.task("noten:db:lehreinheitenOfLv", { lvId: lvs[j].lehrveranstaltung_id, semKurzbz: sem })
|
||||
.then((ids) =>
|
||||
ids.length
|
||||
? { sem, lvId: lvs[j].lehrveranstaltung_id, lehreinheiten: ids.map(String).sort() }
|
||||
: ersteLvMitLehreinheiten(lvs, j + 1),
|
||||
: firstLvWithLehreinheiten(lvs, j + 1),
|
||||
);
|
||||
|
||||
const probiereStudiengang = (i) =>
|
||||
const tryStudiengang = (i) =>
|
||||
i >= studiengaenge.length
|
||||
? cy.wrap(null, { log: false })
|
||||
: getAlsAssistenz("getLvForStudiengang", {
|
||||
: getAsAssistenz("getLvForStudiengang", {
|
||||
studiengang_kz: studiengaenge[i].studiengang_kz,
|
||||
sem_kurzbz: sem,
|
||||
})
|
||||
.then((response) =>
|
||||
ersteLvMitLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0),
|
||||
firstLvWithLehreinheiten(expectNotenSuccess(response, "LVs des Studiengangs"), 0),
|
||||
)
|
||||
.then((ziel) => ziel || probiereStudiengang(i + 1));
|
||||
.then((target) => target || tryStudiengang(i + 1));
|
||||
|
||||
return probiereStudiengang(0);
|
||||
return tryStudiengang(0);
|
||||
});
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
/**
|
||||
* Konfigurationsabhängige Specs.
|
||||
* Configuration-dependent specs.
|
||||
*
|
||||
* CIS_GESAMTNOTE_PUNKTE ist ein PHP define() in config/global.config.inc.php und damit eine
|
||||
* Eigenschaft der INSTANZ, nicht des Requests - ein Testlauf kann den Modus nicht umschalten.
|
||||
* Die Suite deckt deshalb beide Modi ab und überspringt jeweils den unpassenden Teil.
|
||||
* CIS_GESAMTNOTE_PUNKTE is a PHP define() in config/global.config.inc.php and is therefore a
|
||||
* property of the INSTANCE, not of the request. A test run cannot switch modes.
|
||||
* The suite therefore covers both modes and skips the irrelevant part in each case.
|
||||
*
|
||||
* Für die Pipeline heisst das: zwei Jobs gegen je eine Instanz. Damit ein Job nicht grün meldet,
|
||||
* weil in Wahrheit alles übersprungen wurde, gibt NOTEN_PUNKTE_MODUS ('on'|'off') die Erwartung
|
||||
* vor - passt die Instanz nicht dazu, scheitert der Lauf sofort und laut.
|
||||
* For the pipeline, this means: two jobs, each against a single instance. To prevent a job from reporting a pass
|
||||
* when in reality everything was skipped, NOTEN_PUNKTE_MODE (‘on’|'off') specifies the expectation.
|
||||
* If the instance does not match this, the run fails immediately and with a clear error message.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Der einzige Weg, einen Test zu überspringen. `bedingung` wahr -> überspringen, `grund` sagt warum.
|
||||
* In it(function(){...}) / beforeEach(function(){...}) aufrufen, nie in einer Pfeilfunktion: eine
|
||||
* Pfeilfunktion bindet kein `this` und erreicht test.skip() nicht.
|
||||
* The only way to skip a test. `condition` true -> skip; `reason` explains why.
|
||||
* Call within it(function(){...}) / beforeEach(function(){...}), never in an arrow function: an
|
||||
* arrow function does not bind `this` and cannot access test.skip().
|
||||
*
|
||||
* Die require*-Helfer unten nennen je eine fachliche Voraussetzung und rufen ihrerseits skipWenn.
|
||||
* The `require*` helpers below each specify a business requirement and, in turn, call `skipIf`.
|
||||
*/
|
||||
export const skipWenn = (testContext, bedingung, grund) => {
|
||||
if (!bedingung) return;
|
||||
Cypress.log({ name: "skip", message: grund });
|
||||
export const skipIf = (testContext, condition, reason) => {
|
||||
if (!condition) return;
|
||||
Cypress.log({ name: "skip", message: reason });
|
||||
testContext.skip();
|
||||
};
|
||||
|
||||
/** Ist der Punktemodus auf dieser Instanz aktiv? */
|
||||
export const punkteModus = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
|
||||
export const punkteMode = (ctx) => Boolean(ctx.cisConfig.CIS_GESAMTNOTE_PUNKTE);
|
||||
|
||||
/**
|
||||
* In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODUS. Ohne gesetzte Variable
|
||||
* In before() aufrufen: prüft die Instanz gegen NOTEN_PUNKTE_MODE. Ohne gesetzte Variable
|
||||
* (lokaler Lauf) wird nur geloggt.
|
||||
*/
|
||||
export const assertPunkteModus = (ctx) => {
|
||||
const erwartet = String(Cypress.env("NOTEN_PUNKTE_MODUS") || "").toLowerCase();
|
||||
const ist = punkteModus(ctx);
|
||||
export const assertPunkteMode = (ctx) => {
|
||||
const expectedMode = String(Cypress.env("NOTEN_PUNKTE_MODE") || "").toLowerCase();
|
||||
const actualMode = punkteMode(ctx);
|
||||
|
||||
if (erwartet !== "on" && erwartet !== "off") {
|
||||
cy.log(`CIS_GESAMTNOTE_PUNKTE = ${ist} (NOTEN_PUNKTE_MODUS nicht gesetzt)`);
|
||||
if (expectedMode !== "on" && expectedMode !== "off") {
|
||||
cy.log(`CIS_GESAMTNOTE_PUNKTE = ${actualMode} (NOTEN_PUNKTE_MODE nicht gesetzt)`);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
ist,
|
||||
`NOTEN_PUNKTE_MODUS=${erwartet}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${ist}. ` +
|
||||
actualMode,
|
||||
`NOTEN_PUNKTE_MODE=${expectedMode}, die Instanz steht aber auf CIS_GESAMTNOTE_PUNKTE=${actualMode}. ` +
|
||||
"Der Lauf würde sonst grün melden, obwohl der halbe Umfang übersprungen wurde. " +
|
||||
"Flag sitzt in config/global.config.inc.php - cis.config.inc.php wird zu spät geladen.",
|
||||
).to.eq(erwartet === "on");
|
||||
).to.eq(expectedMode === "on");
|
||||
};
|
||||
|
||||
/** Skip, wenn der Punktemodus aus ist. Als erste Zeile in beforeEach(function(){...}) aufrufen. */
|
||||
export const requirePunkteModus = (testContext, ctx) =>
|
||||
skipWenn(testContext, !punkteModus(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
|
||||
export const requirePunkteMode = (testContext, ctx) =>
|
||||
skipIf(testContext, !punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aus.");
|
||||
|
||||
/**
|
||||
* Skip, wenn der Schlüssel aus getCisConfig nicht den Zweig trägt, den der Test prüft. Ein Profil aus
|
||||
* tests/cypress/profiles/noten.js schaltet den anderen Zweig ein.
|
||||
*/
|
||||
export const requireKonfiguration = (testContext, ctx, key, wert) =>
|
||||
skipWenn(
|
||||
export const requireConfig = (testContext, ctx, key, value) =>
|
||||
skipIf(
|
||||
testContext,
|
||||
ctx.cisConfig[key] !== wert,
|
||||
`Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(wert)}.`,
|
||||
ctx.cisConfig[key] !== value,
|
||||
`Übersprungen: ${key} ist ${JSON.stringify(ctx.cisConfig[key])}, der Test braucht ${JSON.stringify(value)}.`,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -67,12 +67,12 @@ export const requireKonfiguration = (testContext, ctx, key, wert) =>
|
||||
* endet die Kette für das Werkzeug vor dem kommissionellen Antritt.
|
||||
*/
|
||||
export const requireWiederholung = (testContext, ctx) => {
|
||||
const ab = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
|
||||
const anlegbar =
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && ab !== null
|
||||
? Math.min(ctx.maxAntritte, ab - 1)
|
||||
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
|
||||
const creatable =
|
||||
ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF === false && fromAntritt !== null
|
||||
? Math.min(ctx.maxAntritte, fromAntritt - 1)
|
||||
: ctx.maxAntritte;
|
||||
skipWenn(testContext, anlegbar < 2, `Übersprungen: das Werkzeug legt nur ${anlegbar} Antritt an.`);
|
||||
skipIf(testContext, creatable < 2, `Übersprungen: das Werkzeug legt nur ${creatable} Antritt an.`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,16 +80,16 @@ export const requireWiederholung = (testContext, ctx) => {
|
||||
* CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF verbietet die Anlage.
|
||||
*/
|
||||
export const requireKommissionellerAntritt = (testContext, ctx) => {
|
||||
const ab = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
|
||||
const anlegbar = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false;
|
||||
const moeglich = anlegbar && ab !== null && ab >= 2 && ab <= ctx.maxAntritte;
|
||||
skipWenn(
|
||||
const fromAntritt = ctx.cisConfig.CIS_GESAMTNOTE_KOMMISSIONELL_AB_ANTRITT ?? null;
|
||||
const creatable = ctx.cisConfig.CIS_GESAMTNOTE_ALLOW_CREATE_KOMMPRUEF !== false;
|
||||
const possible = creatable && fromAntritt !== null && fromAntritt >= 2 && fromAntritt <= ctx.maxAntritte;
|
||||
skipIf(
|
||||
testContext,
|
||||
!moeglich,
|
||||
`Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(ab)}, anlegen ${anlegbar}).`,
|
||||
!possible,
|
||||
`Übersprungen: kein anlegbarer kommissioneller Antritt (ab ${JSON.stringify(fromAntritt)}, anlegen ${creatable}).`,
|
||||
);
|
||||
};
|
||||
|
||||
/** Skip, wenn der Punktemodus an ist. */
|
||||
export const requireNotenModus = (testContext, ctx) =>
|
||||
skipWenn(testContext, punkteModus(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
export const requireNotenMode = (testContext, ctx) =>
|
||||
skipIf(testContext, punkteMode(ctx), "Übersprungen: CIS_GESAMTNOTE_PUNKTE ist aktiv.");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Assertions für die Noten-Fehlerhülle.
|
||||
* Assertions for the grade error message.
|
||||
*
|
||||
* Die Fehler tragen keinen errorCode, nur eine lokalisierte Meldung - daher der Abgleich gegen die
|
||||
* de/en-Templates aus system/phrasesupdate.php. Ein errorCode serverseitig würde das erübrigen.
|
||||
* The errors do not have an errorCode, only a localized message—hence the comparison against the
|
||||
* de/en templates from system/phrasesupdate.php. An errorCode on the server side would make this unnecessary.
|
||||
*/
|
||||
|
||||
const PHRASES = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Fixture-Reset ausschliesslich über eine direkte DB-Verbindung - ein HTTP-Endpunkt, der
|
||||
* Notendaten löscht, ist durch keine Absicherung so sicher wie sein Nichtvorhandensein.
|
||||
* Von der Workstation aus braucht das den SSH-Tunnel (cypress/tasks/sshTunnel.js).
|
||||
* Reset fixtures exclusively via a direct database connection, an HTTP endpoint that
|
||||
* deletes grade data is never as secure as its absence, no matter what safeguards are in place.
|
||||
*/
|
||||
|
||||
import { notenAuth } from "../api/notenApi";
|
||||
|
||||
let available = null;
|
||||
|
||||
/** Probes the DB once per run. Never throws; the caller decides how loudly to fail. */
|
||||
@@ -18,13 +19,11 @@ export const resolveResetStrategy = () => {
|
||||
};
|
||||
|
||||
export const describeFailure = (state) =>
|
||||
` ${state.reason}\n\n` +
|
||||
" Needs a database connection. From a workstation: NOTEN_SSH_TUNNEL=true plus\n" +
|
||||
" NOTEN_SSH_HOST / NOTEN_SSH_USER (and NOTEN_SSH_KEY, unless an agent holds the key).";
|
||||
` ${state.reason}\n\n` + " Needs a database connection: NOTEN_DB_* in tests/cypress/suites/.env.";
|
||||
|
||||
/**
|
||||
* uid to stamp as mitarbeiter_uid / freigabevon_uid.
|
||||
* Not Cypress.env("adminusername"): LDAP accepts "Demolektor1" but tbl_benutzer.uid is
|
||||
* Not Cypress.env("NOTEN_USER"): LDAP accepts "Demolektor1" but tbl_benutzer.uid is
|
||||
* "demolektor1" and the FKs are case sensitive.
|
||||
*/
|
||||
let cachedAuthUid = null;
|
||||
@@ -36,7 +35,7 @@ const authUid = () => {
|
||||
.request({
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID",
|
||||
auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") },
|
||||
auth: notenAuth(),
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.then((response) => {
|
||||
|
||||
@@ -5,10 +5,10 @@ import { expectNotenSuccess } from "./notenErrors";
|
||||
import { resetNotenState, seedBaseline } from "./notenTestData";
|
||||
|
||||
/**
|
||||
* Neuer Antritt (ohne pruefung_id -> validatePruefungAdd). Kein typ auf der Leitung.
|
||||
* New entry (without prüfung_id -> validatePruefungAdd). No type on the line.
|
||||
*
|
||||
* punkte bleibt null: mit CIS_GESAMTNOTE_PUNKTE würde ein punkte >= 0 die Note aus dem
|
||||
* Notenschlüssel neu ableiten und die zu testende Note überschreiben.
|
||||
* Points remain zero: with CIS_GESAMTNOTE_PUNKTE, a points value >= 0 would derive the grade anew from the
|
||||
* grading scale and overwrite the grade being tested.
|
||||
*/
|
||||
export const addPruefung = (context, student, { note, datum }) =>
|
||||
notenApi.saveStudentPruefung({
|
||||
@@ -36,8 +36,8 @@ export const editPruefung = (context, student, { pruefungId, note, datum }) =>
|
||||
});
|
||||
|
||||
/**
|
||||
* Liest den Zustand über die API (getStudentenNoten): was der Server MELDET.
|
||||
* Das Gegenstück ist readLvGesamtnoteViaDb - es liest, was wirklich gespeichert ist.
|
||||
* Reads the status via the API (getStudentenNoten): what the server REPORTS.
|
||||
* The counterpart is readLvGesamtnoteViaDb, it reads what is actually stored.
|
||||
*/
|
||||
export const readStateViaApi = (context) =>
|
||||
notenApi
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Runtime discovery + Fixture-Helfer.
|
||||
* Runtime discovery + fixture helpers.
|
||||
*
|
||||
* Keine fixen Ids: maxAntritte und die Sondernoten-PKs stehen in Server-Config, die nicht im Repo
|
||||
* liegt, und werden daher zur Laufzeit über die API gelesen. Override: NOTEN_SEM / NOTEN_LV_ID.
|
||||
* No fixed IDs: maxAntritte and the special grade PKs are stored in the server configuration, which is not in the repo
|
||||
* and are therefore read at runtime via the API. Override: NOTEN_SEM / NOTEN_LV_ID.
|
||||
*/
|
||||
|
||||
import { notenApi } from "../api/notenApi";
|
||||
import { notenApi, notenAuth } from "../api/notenApi";
|
||||
import { expectNotenSuccess } from "./notenErrors";
|
||||
// the client rule; it now only reads back what the server derived, so a mismatch is a config bug
|
||||
import { maxAntrittCount as computeMaxAntritte } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
performSeedZeugnisnote,
|
||||
resolveResetStrategy,
|
||||
} from "./notenReset";
|
||||
import { assertPunkteModus } from "./notenConfig";
|
||||
import { assertPunkteMode } from "./notenConfig";
|
||||
|
||||
const BEZ_ENTSCHULDIGT = "entschuldigt";
|
||||
const BEZ_NOCH_NICHT = "Noch nicht eingetragen";
|
||||
@@ -65,35 +65,27 @@ export const fristHasPassed = (semKurzbz, ssConfig = { month: 11, day: 15 }, wsC
|
||||
* @param {string} type "SS" or "WS"
|
||||
* @returns {Cypress.Chainable<{semKurzbz: string, lvId: number}|null>}
|
||||
*/
|
||||
export const lehrsemesterMitAbgelaufenerFrist = (type = "SS", ssConfig, wsConfig) => {
|
||||
const jahr = new Date().getFullYear();
|
||||
const kandidaten = [];
|
||||
for (let y = jahr; y >= jahr - 6; y -= 1) {
|
||||
export const teachingSemesterWithExpiredFrist = (type = "SS", ssConfig, wsConfig) => {
|
||||
const year = new Date().getFullYear();
|
||||
const candidates = [];
|
||||
for (let y = year; y >= year - 6; y -= 1) {
|
||||
const sem = `${type}${y}`;
|
||||
if (fristHasPassed(sem, ssConfig, wsConfig)) kandidaten.push(sem);
|
||||
if (fristHasPassed(sem, ssConfig, wsConfig)) candidates.push(sem);
|
||||
}
|
||||
|
||||
const probiere = (i) => {
|
||||
if (i >= kandidaten.length) return cy.wrap(null, { log: false });
|
||||
const trySemester = (i) => {
|
||||
if (i >= candidates.length) return cy.wrap(null, { log: false });
|
||||
|
||||
return notenApi.getBenotungstoolContext(kandidaten[i]).then((response) => {
|
||||
return notenApi.getBenotungstoolContext(candidates[i]).then((response) => {
|
||||
const lvs = (response.body && response.body.data && response.body.data.lehrveranstaltungen) || [];
|
||||
if (lvs.length > 0) {
|
||||
return { semKurzbz: kandidaten[i], lvId: lvs[0].lehrveranstaltung_id };
|
||||
return { semKurzbz: candidates[i], lvId: lvs[0].lehrveranstaltung_id };
|
||||
}
|
||||
return probiere(i + 1);
|
||||
return trySemester(i + 1);
|
||||
});
|
||||
};
|
||||
|
||||
return probiere(0);
|
||||
};
|
||||
|
||||
export const semesterWithPastFrist = (type = "SS") => {
|
||||
const year = new Date().getFullYear();
|
||||
for (let y = year; y >= year - 6; y -= 1) {
|
||||
if (fristHasPassed(`${type}${y}`)) return `${type}${y}`;
|
||||
}
|
||||
return `${type}${year - 6}`;
|
||||
return trySemester(0);
|
||||
};
|
||||
|
||||
// --- discovery ---
|
||||
@@ -107,7 +99,7 @@ const resolveSemester = () => {
|
||||
method: "GET",
|
||||
url: "/index.ci.php/api/frontend/v1/organisation/Studiensemester/getAll",
|
||||
qs: { order: "DESC" },
|
||||
auth: { username: Cypress.env("adminusername"), password: Cypress.env("adminpassword") },
|
||||
auth: notenAuth(),
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -157,7 +149,7 @@ export const loadNotenContext = () => {
|
||||
context.maxAntritte = computeMaxAntritte(context.cisConfig);
|
||||
// ein Lauf, der den falschen Konfigurationsmodus erwartet, soll hier scheitern und nicht
|
||||
// alles stillschweigend überspringen
|
||||
assertPunkteModus(context);
|
||||
assertPunkteMode(context);
|
||||
return notenApi.getNoten();
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -190,10 +182,10 @@ export const loadNotenContext = () => {
|
||||
expect(context.gradeNotes.length, "need two ordinary grades to vary one on edit").to.be.greaterThan(1);
|
||||
|
||||
// administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen")
|
||||
const nichtLehre = noten.find((n) => n.lehre === false);
|
||||
const notLehre = noten.find((n) => n.lehre === false);
|
||||
|
||||
// as a ZEUGNISnote this one locks the LV note, in the client and in the server
|
||||
const nichtUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
|
||||
const notUeberschreibbar = noten.find((n) => n.lkt_ueberschreibbar === false);
|
||||
|
||||
// Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote
|
||||
const angerechnet = byBezeichnung("angerechnet");
|
||||
@@ -202,9 +194,9 @@ export const loadNotenContext = () => {
|
||||
// An attempt chain needs grades by meaning, not by index: a positive grade closes the
|
||||
// chain, so a repeat after "Sehr Gut" is not a test case but an impossible flow.
|
||||
const abschliessend = (context.cisConfig.NOTEN_ABSCHLIESSEND || []).map(String);
|
||||
const inSkala = (n) => Number(n.note) >= 1 && Number(n.note) <= 5;
|
||||
const negativNoten = usable.filter((n) => !n.positiv && inSkala(n)).map((n) => n.note);
|
||||
const positivNoten = usable.filter((n) => n.positiv && inSkala(n)).map((n) => n.note);
|
||||
const inScale = (n) => Number(n.note) >= 1 && Number(n.note) <= 5;
|
||||
const negativNoten = usable.filter((n) => !n.positiv && inScale(n)).map((n) => n.note);
|
||||
const positivNoten = usable.filter((n) => n.positiv && inScale(n)).map((n) => n.note);
|
||||
const verbesserbar = positivNoten.filter((n) => !abschliessend.includes(String(n)));
|
||||
|
||||
expect(negativNoten.length, "need a negative grade to build an attempt chain").to.be.greaterThan(0);
|
||||
@@ -218,8 +210,8 @@ export const loadNotenContext = () => {
|
||||
bestnote: positivNoten.find((n) => abschliessend.includes(String(n))) || null,
|
||||
entschuldigt: entschuldigt.note,
|
||||
nochNichtEingetragen: nochNicht.note,
|
||||
nichtLehre: nichtLehre ? nichtLehre.note : null,
|
||||
nichtUeberschreibbar: nichtUeberschreibbar ? nichtUeberschreibbar.note : null,
|
||||
notLehre: notLehre ? notLehre.note : null,
|
||||
notUeberschreibbar: notUeberschreibbar ? notUeberschreibbar.note : null,
|
||||
angerechnet: angerechnet ? angerechnet.note : null,
|
||||
internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
|
||||
};
|
||||
@@ -249,11 +241,11 @@ export const loadNotenContext = () => {
|
||||
export const resetNotenState = (context, studentUids) => performReset(context, studentUids || context.studentUids);
|
||||
|
||||
/**
|
||||
* Baseline für Antritt 1: freigegebene LV-Note PLUS die Prüfungszeile dazu - das ist, was die
|
||||
* Freigabe produziert. Ohne die Zeile zählt die LV-Note nur solange gar kein Termin existiert, und
|
||||
* der erste hinzugefügte Antritt würde Antritt 1 ersetzen statt Antritt 2 zu werden.
|
||||
* Baseline for Antritt 1: approved course grade PLUS the corresponding exam row—that is what the
|
||||
* approval generates. Without that row, the course grade only counts as long as no date exists, and
|
||||
* the first attempt added would replace Attempt 1 instead of becoming Attempt 2.
|
||||
*
|
||||
* `erstantritt: false` (bzw. `freigegeben: false`) seedet die Altdaten-Form ohne diese Zeile.
|
||||
* `first_attempt: false` (or `approved: false`) seeds the legacy data form without this line.
|
||||
*/
|
||||
export const seedBaseline = (context, student, options = {}) => {
|
||||
expect(student, "seedBaseline braucht das Studierenden-Objekt aus context.students, nicht die uid").to.be.an(
|
||||
@@ -263,13 +255,13 @@ export const seedBaseline = (context, student, options = {}) => {
|
||||
// Defaults to a NEGATIVE grade: only after one may another attempt follow. Pass
|
||||
// context.notes.bestnote explicitly to close the chain.
|
||||
const note = options.note !== undefined ? options.note : context.notes.negativ;
|
||||
// Mit CIS_GESAMTNOTE_FREIGABE_FINAL ist eine freigegebene Note endgültig. Ohne ausdrückliches
|
||||
// freigegeben sät die Baseline dann eine offene Note, damit ein Test weitere Termine anlegen kann.
|
||||
// With CIS_GESAMTNOTE_FREIGABE_FINAL, a released grade is final. Without an explicit
|
||||
// release, the baseline sets the grade to “open” so that a test can create additional dates.
|
||||
const freigegeben =
|
||||
options.freigegeben !== undefined
|
||||
? options.freigegeben
|
||||
: context.cisConfig.CIS_GESAMTNOTE_FREIGABE_FINAL !== true;
|
||||
// Antritt 1 fehlt nur bei ausdrücklichem erstantritt: false oder freigegeben: false
|
||||
// Start 1 is missing only if the initial start is explicitly set to false or enabled: false
|
||||
const erstantritt = options.erstantritt !== undefined ? options.erstantritt : options.freigegeben !== false;
|
||||
|
||||
return performSeed(context, student.uid, {
|
||||
@@ -285,26 +277,27 @@ export const seedBaseline = (context, student, options = {}) => {
|
||||
lehreinheitId: student.lehreinheit_id,
|
||||
note,
|
||||
datum: baselineDate(context),
|
||||
typ: "Termin1", // legacy projection of Antritt 1; the rules never read it back
|
||||
type: "Termin1", // legacy projection of Antritt 1; the rules never read it back
|
||||
}).then(() => seeded);
|
||||
});
|
||||
};
|
||||
|
||||
/** -> { pruefungId, note, datum, typ }. For attempt states the API cannot build. */
|
||||
export const seedPruefung = (context, student, { note, datum, typ }) =>
|
||||
export const seedPruefung = (context, student, { note, datum, type }) =>
|
||||
performSeedPruefung(context, student.uid, {
|
||||
lehreinheitId: student.lehreinheit_id,
|
||||
note,
|
||||
datum,
|
||||
typ,
|
||||
type,
|
||||
});
|
||||
|
||||
/** Zeugnisnote setzen. Nur die Studierendenverwaltung schreibt sie, kein Endpunkt dieses Tools. */
|
||||
/** Set the grade on the transcript. Only the student administration system (stv) enters it,
|
||||
* this tool does not handle that. */
|
||||
export const seedZeugnisnote = (context, studentUid, note) => performSeedZeugnisnote(context, studentUid, { note });
|
||||
|
||||
/**
|
||||
* Liest die LV-Note direkt aus der DATENBANK: was wirklich gespeichert ist, ohne den
|
||||
* freigabedatum-Filter von getLvGesamtNoten. Das Gegenstück ist readStateViaApi.
|
||||
* Reads the course grade directly from the DB: what is actually stored, without the
|
||||
* “release date” filter from getLvGesamtNoten. The counterpart is readStateViaApi.
|
||||
*/
|
||||
export const readLvGesamtnoteViaDb = (context, studentUid) => performRead(context, studentUid);
|
||||
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { notenAuth } from "../api/notenApi";
|
||||
import { waitForOk } from "../helpers/network";
|
||||
|
||||
/**
|
||||
* Page object für das Benotungstool.
|
||||
* Page object for the grading tool.
|
||||
*
|
||||
* Selektoren sind data-cy-Attribute aus Benotungstool.js. Die Tabelle ist Tabulator: Zeilen tragen
|
||||
* data-cy="student-row-<uid>", Zellen das von Tabulator gesetzte tabulator-field, und der
|
||||
* Freigabestatus steckt als data-state am Zellinhalt statt am Icon.
|
||||
* Selectors are data-cy attributes from Benotungstool.js. The table is a tabulator: rows contain
|
||||
* data-cy=“student-row-<uid>”, cells contain the tabulator-field set by the tabulator, and the
|
||||
* approval status is stored as data-state in the cell content rather than in the icon.
|
||||
*
|
||||
* Nach jeder schreibenden Aktion wird auf den zugehörigen Request gewartet (waitForOk), nicht auf
|
||||
* eine DOM-Änderung - sonst prüfen die Specs gegen den Stand vor der Antwort. Die submit-Methoden
|
||||
* warten nicht; mit ihnen prüft ein Test die Ablehnung durch den Server.
|
||||
* After every write operation, the system waits for the corresponding request (waitForOk), not for
|
||||
* a DOM change. Otherwise, the specs would check against the state prior to the response. The submit methods
|
||||
* do not wait; a test uses them to check for rejection by the server.
|
||||
*/
|
||||
|
||||
const TABLE_TIMEOUT = 60_000;
|
||||
const API = "**/api/frontend/v1/Noten";
|
||||
|
||||
/**
|
||||
* Exakter Textvergleich für Optionslisten. Nötig, weil die Notenbezeichnungen einander enthalten:
|
||||
* contains("Gut") trifft zuerst "Sehr Gut", contains("Genügend") zuerst "Nicht Genügend".
|
||||
* Exact text comparison for option lists. This is necessary because the grade labels are subsets of one another:
|
||||
* contains(“Gut”) matches “Sehr Gut” first, and contains(‘Genügend’) matches “Nicht Genügend” first.
|
||||
*/
|
||||
const exactText = (text) => new RegExp(`^\\s*${String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
|
||||
|
||||
@@ -40,20 +41,21 @@ class BenotungstoolPage {
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep-Link auf LV + Semester - die vier Dropdowns müssen dafür nicht bedient werden.
|
||||
* Die Spaltenaufteilung wird vor dem Laden gesetzt: die Komponente liest den localStorage beim
|
||||
* Initialisieren, und vor dem ersten visit gehört er noch about:blank.
|
||||
* Deep link to LV + Semester. The four dropdown menus do not need to be used for this.
|
||||
* The column layout is set before loading: the component reads localStorage during
|
||||
* initialization, and before the first visit, it still belongs to about:blank.
|
||||
*/
|
||||
visit = (ctx, { spalten = "antritt" } = {}) =>
|
||||
visit = (ctx, { columns = "antritt" } = {}) =>
|
||||
cy.visit(`/cis.php/Cis/Benotungstool/${ctx.semKurzbz}/${ctx.lvId}`, {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem("notenToolPruefungsspalten", spalten);
|
||||
win.localStorage.setItem("notenToolPruefungsspalten", columns);
|
||||
win.localStorage.removeItem("notenToolStickyCols");
|
||||
},
|
||||
});
|
||||
|
||||
visitAndWaitForTable = (ctx, options) => {
|
||||
cy.login();
|
||||
const { username, password } = notenAuth();
|
||||
cy.login(username, password);
|
||||
this.setupIntercepts();
|
||||
this.visit(ctx, options);
|
||||
|
||||
@@ -70,13 +72,14 @@ class BenotungstoolPage {
|
||||
getCell = (uid, field) => this.getRow(uid).find(`[tabulator-field='${field}']`);
|
||||
|
||||
getFreigabeState = (uid) => this.getCell(uid, "freigegeben").find("[data-cy='freigabe-state']");
|
||||
getPruefungCell = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='pruefung-cell']");
|
||||
getPruefungCell = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']");
|
||||
getUebernehmenButton = (uid) => this.getCell(uid, "übernehmen").find("[data-cy='btn-uebernehmen']");
|
||||
getPruefungAddButton = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='btn-pruefung-add']");
|
||||
getPruefungEditButton = (uid, spalte) => this.getCell(uid, spalte).find("[data-cy='btn-pruefung-edit']");
|
||||
getPruefungAddButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-add']");
|
||||
getPruefungEditButton = (uid, column) => this.getCell(uid, column).find("[data-cy='btn-pruefung-edit']");
|
||||
getBestandenHint = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-bestanden']");
|
||||
|
||||
getPruefungModal = () => cy.get("[data-cy='modal-pruefung']");
|
||||
getNeuePruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']");
|
||||
getNewPruefungModal = () => cy.get("[data-cy='modal-neue-pruefung']");
|
||||
getFreigabeModal = () => cy.get("[data-cy='modal-freigabe']");
|
||||
getUebernahmeModal = () => cy.get("[data-cy='modal-uebernahme']");
|
||||
getNotenImportModal = () => cy.get("[data-cy='modal-noten-import']");
|
||||
@@ -99,131 +102,130 @@ class BenotungstoolPage {
|
||||
? this.getCell(uid, "hoechsterAntritt").invoke("text").invoke("trim").should("eq", "")
|
||||
: this.getCell(uid, "hoechsterAntritt").should("contain.text", String(count));
|
||||
|
||||
expectPruefung = (uid, spalte, { note, antritt } = {}) => {
|
||||
expectPruefung = (uid, column, { note, antritt } = {}) => {
|
||||
// je Assertion neu abfragen, damit Cypress ein Re-Render der Zelle erneut versucht
|
||||
if (note !== undefined) {
|
||||
this.getPruefungCell(uid, spalte).should("have.attr", "data-note", String(note));
|
||||
this.getPruefungCell(uid, column).should("have.attr", "data-note", String(note));
|
||||
}
|
||||
if (antritt !== undefined) {
|
||||
this.getPruefungCell(uid, spalte).should("have.attr", "data-attempt", String(antritt));
|
||||
this.getPruefungCell(uid, column).should("have.attr", "data-attempt", String(antritt));
|
||||
}
|
||||
};
|
||||
|
||||
expectKeinePruefung = (uid, spalte) =>
|
||||
this.getCell(uid, spalte).find("[data-cy='pruefung-cell']").should("not.exist");
|
||||
expectNoPruefung = (uid, column) => this.getCell(uid, column).find("[data-cy='pruefung-cell']").should("not.exist");
|
||||
|
||||
expectKeineAntrittsspalte = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
|
||||
expectNoAntrittColumn = (nr) => cy.get(`[tabulator-field='antritt_${nr}']`).should("not.exist");
|
||||
|
||||
/**
|
||||
* Der Server lehnt eine Speicherung ab, und ein Toast zeigt seine Meldung. Die Meldung kommt aus
|
||||
* der Antwort, deshalb schreibt der Test keine Phrase fest.
|
||||
* The server refuses to save the data, and a toast message displays its message. The message comes from
|
||||
* the response, so the test does not set a phrase.
|
||||
*/
|
||||
expectAbgelehnt = (alias) =>
|
||||
expectRejected = (alias) =>
|
||||
cy.wait(alias).then(({ response }) => {
|
||||
expect(response.statusCode, `${alias} lehnt ab`).to.not.eq(200);
|
||||
const meldung = (response.body?.errors ?? [])[0]?.message;
|
||||
expect(meldung, `Meldung von ${alias}`).to.be.a("string").and.not.be.empty;
|
||||
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", meldung);
|
||||
const errorMessage = (response.body?.errors ?? [])[0]?.message;
|
||||
expect(errorMessage, `Meldung von ${alias}`).to.be.a("string").and.not.be.empty;
|
||||
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage);
|
||||
});
|
||||
|
||||
/** Ein Sammelpfad lehnt eine Zeile ab: HTTP 200, die Meldung steht in data[uid] und im Toast. */
|
||||
expectZeileAbgelehnt = (alias, uid) =>
|
||||
/** A collection path rejects a row: HTTP 200; the message is stored in data[uid] and in the toast. */
|
||||
expectRowRejected = (alias, uid) =>
|
||||
cy.wait(alias).then(({ response }) => {
|
||||
expect(response.statusCode, `${alias} antwortet`).to.eq(200);
|
||||
const meldung = response.body?.data?.[uid];
|
||||
expect(meldung, `Ablehnung der Zeile ${uid}`).to.be.a("string").and.not.be.empty;
|
||||
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", meldung);
|
||||
const errorMessage = response.body?.data?.[uid];
|
||||
expect(errorMessage, `Ablehnung der Zeile ${uid}`).to.be.a("string").and.not.be.empty;
|
||||
cy.get(".p-toast-message", { timeout: TABLE_TIMEOUT }).should("contain.text", errorMessage);
|
||||
});
|
||||
|
||||
/** Nach einer ganz abgelehnten Aktion meldet die Oberfläche keinen Erfolg. */
|
||||
expectKeinErfolg = () => cy.get(".p-toast-message-success").should("not.exist");
|
||||
/** After an action is completely rejected, the interface reports that it was unsuccessful. */
|
||||
expectNoSuccess = () => cy.get(".p-toast-message-success").should("not.exist");
|
||||
|
||||
/** Eine Warnung des Clients. Warnungen haben keine Ablaufzeit. */
|
||||
expectWarnung = (text) => cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("contain.text", text);
|
||||
/** A client warning. Warnings do not have an expiration time. */
|
||||
expectWarning = (text) => cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("contain.text", text);
|
||||
|
||||
expectWarnungen = (anzahl) =>
|
||||
cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", anzahl);
|
||||
expectWarnings = (minCount) =>
|
||||
cy.get(".p-toast-message-warn", { timeout: TABLE_TIMEOUT }).should("have.length.at.least", minCount);
|
||||
|
||||
/** Die uids des letzten Requests eines Sammelpfads. feld: "noten" oder "pruefungen" */
|
||||
gesendeteUids = (alias, feld) =>
|
||||
/** The UIDs of the last request in a collection path. Field: “noten” or “pruefungen” */
|
||||
sentUids = (alias, bodyField) =>
|
||||
cy
|
||||
.get(alias)
|
||||
.its(`request.body.${feld}`)
|
||||
.then((zeilen) => zeilen.map((z) => z.uid));
|
||||
.its(`request.body.${bodyField}`)
|
||||
.then((sentRows) => sentRows.map((z) => z.uid));
|
||||
|
||||
// --- Notenvorschlag --------------------------------------------------------------------------
|
||||
|
||||
/** Öffnet den Tabulator-Listeneditor der Vorschlagsspalte und wählt die Bezeichnung. */
|
||||
/** Opens the tab-separated list editor for the suggestion column and selects the label. */
|
||||
setNotenvorschlag = (uid, bezeichnung) => {
|
||||
this.getCell(uid, "note_vorschlag").click();
|
||||
cy.get(".tabulator-edit-list-item").contains(bezeichnung).click();
|
||||
};
|
||||
|
||||
/** Übernehmen fragt zuerst das Benotungsdatum. Ohne `datum` bleibt der Vorschlag des Dialogs. */
|
||||
/** “Übernehmen” first asks for the grading date. If `datum` is omitted, the dialog's default value is used. */
|
||||
submitUebernahme = (uid, { datum } = {}) => {
|
||||
this.schliesseToasts();
|
||||
this.closeToasts();
|
||||
this.getUebernehmenButton(uid).click();
|
||||
this.getUebernahmeModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("uebernahme-datum", datum);
|
||||
if (datum) this.setDate("uebernahme-datum", datum);
|
||||
|
||||
cy.get("[data-cy='uebernahme-submit']").click();
|
||||
};
|
||||
|
||||
uebernehmen = (uid, optionen) => {
|
||||
this.submitUebernahme(uid, optionen);
|
||||
uebernehmen = (uid, options) => {
|
||||
this.submitUebernahme(uid, options);
|
||||
waitForOk("@saveNotenvorschlag");
|
||||
this.getUebernahmeModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Prüfungen -------------------------------------------------------------------------------
|
||||
|
||||
/** PrimeVue hängt seine Panels an den body, daher nicht innerhalb des Modals suchen. */
|
||||
/** PrimeVue attaches its panels to the `body`, so don't search inside the modal. */
|
||||
selectDropdownOption = (dataCy, label) => {
|
||||
cy.get(`[data-cy='${dataCy}']`).click();
|
||||
cy.contains(".p-dropdown-panel .p-dropdown-item", exactText(label)).click();
|
||||
};
|
||||
|
||||
setDatum = (dataCy, ddmmyyyy) => cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
|
||||
setDate = (dataCy, ddmmyyyy) => cy.get(`[data-cy='${dataCy}'] input`).first().clear().type(`${ddmmyyyy}{enter}`);
|
||||
|
||||
/**
|
||||
* Nach jeder Speicherung zeigt das Tool einen Erfolgs-Toast ohne Ablaufzeit. Er liegt über dem Modal
|
||||
* (z-index 100001) und verdeckt dessen Knöpfe, deshalb schliesst ihn der Test über seinen Knopf.
|
||||
* After each save, the tool displays a success toast with no timeout. It is positioned above the modal
|
||||
* (z-index 100001) and covers its buttons, which is why the test closes it using its button.
|
||||
*/
|
||||
schliesseToasts = () =>
|
||||
closeToasts = () =>
|
||||
cy
|
||||
.get("body")
|
||||
.then(($body) => {
|
||||
const knoepfe = $body.find(".p-toast-message .p-toast-icon-close");
|
||||
if (knoepfe.length) cy.wrap(knoepfe).click({ multiple: true });
|
||||
const buttons = $body.find(".p-toast-message .p-toast-icon-close");
|
||||
if (buttons.length) cy.wrap(buttons).click({ multiple: true });
|
||||
})
|
||||
.then(() => cy.get(".p-toast-message").should("not.exist"));
|
||||
|
||||
/** Dialog aus der Tabellenzelle: neuer Antritt für EINEN Studenten. */
|
||||
submitPruefungInCell = (uid, spalte, { note, datum } = {}) => {
|
||||
this.schliesseToasts();
|
||||
this.getPruefungAddButton(uid, spalte).click();
|
||||
/** Dialog from the table cell: new enrollment for ONE student. */
|
||||
submitPruefungInCell = (uid, column, { note, datum } = {}) => {
|
||||
this.closeToasts();
|
||||
this.getPruefungAddButton(uid, column).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("pruefung-datum", datum);
|
||||
if (datum) this.setDate("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
};
|
||||
|
||||
addPruefungInCell = (uid, spalte, optionen) => {
|
||||
this.submitPruefungInCell(uid, spalte, optionen);
|
||||
addPruefungInCell = (uid, column, options) => {
|
||||
this.submitPruefungInCell(uid, column, options);
|
||||
waitForOk("@saveStudentPruefung");
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
/** Bestehenden Antritt bearbeiten. Ohne `note` bleibt sie unverändert (Datumskorrektur). */
|
||||
editPruefungInCell = (uid, spalte, { note, datum } = {}) => {
|
||||
this.schliesseToasts();
|
||||
this.getPruefungEditButton(uid, spalte).click();
|
||||
/** Edit an existing Antrittsdatum. If `note` is omitted, the start date remains unchanged (date correction). */
|
||||
editPruefungInCell = (uid, column, { note, datum } = {}) => {
|
||||
this.closeToasts();
|
||||
this.getPruefungEditButton(uid, column).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("pruefung-datum", datum);
|
||||
if (datum) this.setDate("pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("pruefung-note", note);
|
||||
|
||||
cy.get("[data-cy='pruefung-submit']").click();
|
||||
@@ -231,39 +233,39 @@ class BenotungstoolPage {
|
||||
this.getPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
openPruefungModalForEdit = (uid, spalte) => {
|
||||
this.schliesseToasts();
|
||||
this.getPruefungEditButton(uid, spalte).click();
|
||||
openPruefungModalForEdit = (uid, column) => {
|
||||
this.closeToasts();
|
||||
this.getPruefungEditButton(uid, column).click();
|
||||
this.getPruefungModal().should("be.visible");
|
||||
};
|
||||
|
||||
/** Sammelanlage über die Toolbar abschicken, ohne auf die Antwort zu warten. */
|
||||
/** Submit the batch request via import modal without waiting for a response. */
|
||||
submitPruefungBulk = ({ uids, note, punkte, datum }) => {
|
||||
this.schliesseToasts();
|
||||
this.closeToasts();
|
||||
cy.get("[data-cy='btn-neue-pruefung']").click();
|
||||
this.getNeuePruefungModal().should("be.visible");
|
||||
this.getNewPruefungModal().should("be.visible");
|
||||
|
||||
if (datum) this.setDatum("neue-pruefung-datum", datum);
|
||||
if (datum) this.setDate("neue-pruefung-datum", datum);
|
||||
if (note) this.selectDropdownOption("neue-pruefung-note", note);
|
||||
if (punkte !== undefined) this.setNeuePruefungPunkte(punkte);
|
||||
if (punkte !== undefined) this.setNewPruefungPunkte(punkte);
|
||||
|
||||
cy.get("[data-cy='neue-pruefung-studenten']").click();
|
||||
// das Label ist "uid – Nachname Vorname – Antritte: n", daher hier bewusst per Teilstring
|
||||
// The label is “uid – Last Name First Name – Appearances: n”
|
||||
uids.forEach((uid) => cy.contains(".p-multiselect-panel .p-multiselect-item", uid).click());
|
||||
|
||||
// NICHT mit Escape: das schliesst das Bootstrap-Modal mit. Das offene Panel kann den Trigger
|
||||
// verdecken, deshalb über den Schliessknopf des Panels.
|
||||
// DO NOT use the Escape key: that closes the Bootstrap modal as well. The open panel may cover the trigger,
|
||||
// so use the panel's close button instead.
|
||||
cy.get(".p-multiselect-panel .p-multiselect-close").click();
|
||||
cy.get(".p-multiselect-panel").should("not.exist");
|
||||
|
||||
cy.get("[data-cy='neue-pruefung-submit']").click();
|
||||
};
|
||||
|
||||
/** Sammelanlage über die Toolbar, für mehrere Studierende auf einmal. */
|
||||
addPruefungBulk = (optionen) => {
|
||||
this.submitPruefungBulk(optionen);
|
||||
/** Bulk insert for multiple students. */
|
||||
addPruefungBulk = (options) => {
|
||||
this.submitPruefungBulk(options);
|
||||
waitForOk("@createPruefungen");
|
||||
this.getNeuePruefungModal().should("not.be.visible");
|
||||
this.getNewPruefungModal().should("not.be.visible");
|
||||
};
|
||||
|
||||
// --- Freigabe --------------------------------------------------------------------------------
|
||||
@@ -278,12 +280,12 @@ class BenotungstoolPage {
|
||||
.find("[data-cy='freigabe-row-released']")
|
||||
.should("contain.text", releasedBezeichnung);
|
||||
|
||||
typeFreigabePasswort = (password) => cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false });
|
||||
typeFreigabePassword = (password) => cy.get("[data-cy='freigabe-passwort'] input").type(password, { log: false });
|
||||
|
||||
submitFreigabe = () => cy.get("[data-cy='freigabe-submit']").click();
|
||||
|
||||
freigeben = (password) => {
|
||||
this.typeFreigabePasswort(password);
|
||||
this.typeFreigabePassword(password);
|
||||
this.submitFreigabe();
|
||||
waitForOk("@saveStudentenNoten");
|
||||
this.getFreigabeModal().should("not.be.visible");
|
||||
@@ -291,9 +293,9 @@ class BenotungstoolPage {
|
||||
|
||||
// --- Import ----------------------------------------------------------------------------------
|
||||
|
||||
/** rows: [[uid, note], ...] -> "uid<TAB>note" je Zeile */
|
||||
/** rows: [[uid, note], ...] -> "uid<TAB>note" per row */
|
||||
submitNotenImport = (rows) => {
|
||||
this.schliesseToasts();
|
||||
this.closeToasts();
|
||||
cy.get("[data-cy='btn-noten-import']").click();
|
||||
this.getNotenImportModal().should("be.visible");
|
||||
|
||||
@@ -309,7 +311,7 @@ class BenotungstoolPage {
|
||||
|
||||
/** rows: [[uid, "dd.MM.yyyy", note], ...] */
|
||||
submitPruefungImport = (rows) => {
|
||||
this.schliesseToasts();
|
||||
this.closeToasts();
|
||||
cy.get("[data-cy='btn-pruefung-import']").click();
|
||||
this.getPruefungImportModal().should("be.visible");
|
||||
|
||||
@@ -350,18 +352,18 @@ class BenotungstoolPage {
|
||||
expectPunkte = (uid, punkte) => this.getPunkteCell(uid).should("contain.text", String(punkte));
|
||||
|
||||
/** Sobald ein Termin existiert, ist die Punktespalte gesperrt (editable-Guard der Spalte). */
|
||||
expectPunkteZelleGesperrt = (uid) => {
|
||||
expectPunkteCellLocked = (uid) => {
|
||||
this.getPunkteCell(uid).click();
|
||||
this.getPunkteCell(uid).find("input").should("not.exist");
|
||||
};
|
||||
|
||||
/** Im Punktemodus ist die Vorschlagsspalte nicht editierbar - die Note kommt aus den Punkten. */
|
||||
expectNotenvorschlagGesperrt = (uid) => {
|
||||
expectNotenvorschlagLocked = (uid) => {
|
||||
this.getCell(uid, "note_vorschlag").click();
|
||||
cy.get(".tabulator-edit-list").should("not.exist");
|
||||
};
|
||||
|
||||
expectNoteFeldGesperrt = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
|
||||
expectNoteFieldLocked = (dataCy) => cy.get(`[data-cy='${dataCy}']`).should("have.class", "p-disabled");
|
||||
|
||||
/** Punktefeld im Einzeldialog; löst die Ableitung der Note aus. */
|
||||
setPruefungPunkte = (punkte) => {
|
||||
@@ -370,7 +372,7 @@ class BenotungstoolPage {
|
||||
};
|
||||
|
||||
/** Punktefeld der Sammelanlage; dort leitet erst der Server beim Speichern ab. */
|
||||
setNeuePruefungPunkte = (punkte) => cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte));
|
||||
setNewPruefungPunkte = (punkte) => cy.get("[data-cy='neue-pruefung-punkte'] input").clear().type(String(punkte));
|
||||
|
||||
// --- Hilfen ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -383,7 +385,7 @@ class BenotungstoolPage {
|
||||
};
|
||||
|
||||
/** Date in the configured import format; kept apart from toDDMMYYYY, which the dialog uses. */
|
||||
importDatum = (isoDate, format) => (format === "yyyy-MM-dd" ? isoDate : this.toDDMMYYYY(isoDate));
|
||||
importDate = (isoDate, format) => (format === "yyyy-MM-dd" ? isoDate : this.toDDMMYYYY(isoDate));
|
||||
}
|
||||
|
||||
export const benotungstoolPage = new BenotungstoolPage();
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
* Connection, write guard, pooling and teardown live here. What to delete or seed belongs in a
|
||||
* suite-specific task file -- see notenDb.js.
|
||||
*
|
||||
* Settings come from <PREFIX>_DB_* and <PREFIX>_SSH_*, where PREFIX is TEST_ENV_PREFIX. That
|
||||
* indirection is the point: another suite sets its own prefix and reuses this file untouched.
|
||||
* Settings come from <PREFIX>_DB_*, where PREFIX is TEST_ENV_PREFIX. That indirection is the point:
|
||||
* another suite sets its own prefix and reuses this file untouched.
|
||||
*/
|
||||
|
||||
const { ensureTunnel, closeTunnel, tunnelPort } = require("./sshTunnel");
|
||||
|
||||
const PREFIX = process.env.TEST_ENV_PREFIX || "TEST";
|
||||
|
||||
const env = (key) => process.env[`${PREFIX}_DB_${key}`];
|
||||
const sshEnv = (key) => process.env[`${PREFIX}_SSH_${key}`];
|
||||
|
||||
const dbConfigured = () => Boolean(env("HOST") && env("NAME") && env("USER"));
|
||||
const writesAllowed = () => String(env("ALLOW_WRITES")).toLowerCase() === "true";
|
||||
@@ -26,12 +23,9 @@ const getPool = () => {
|
||||
// lazy require so the suite loads without pg when no DB task is used
|
||||
const { Pool } = require("pg");
|
||||
|
||||
// with a tunnel up, bind to its local end - that is what gets us past pg_hba
|
||||
const forwarded = tunnelPort();
|
||||
|
||||
pool = new Pool({
|
||||
host: forwarded ? "127.0.0.1" : env("HOST"),
|
||||
port: forwarded || Number(env("PORT") || 5432),
|
||||
host: env("HOST"),
|
||||
port: Number(env("PORT") || 5432),
|
||||
database: env("NAME"),
|
||||
user: env("USER"),
|
||||
password: env("PASSWORD"),
|
||||
@@ -87,7 +81,7 @@ const explainFailure = (error) => {
|
||||
const host = (msg.match(/for host "([^"]+)"/) || [])[1] || "this machine";
|
||||
return (
|
||||
`the server refused the connection from ${host} - pg_hba.conf has no rule allowing it. ` +
|
||||
`Use the SSH tunnel (${PREFIX}_SSH_TUNNEL=true) or have an entry added. Details: ${msg}`
|
||||
`Run from a host the database admits, or have an entry added. Details: ${msg}`
|
||||
);
|
||||
}
|
||||
if (/authentication failed/i.test(msg)) return `credentials rejected. Details: ${msg}`;
|
||||
@@ -97,25 +91,11 @@ const explainFailure = (error) => {
|
||||
return msg;
|
||||
};
|
||||
|
||||
/** Opens the tunnel if configured, then proves the connection works. */
|
||||
/** Proves the connection works. */
|
||||
const checkAvailability = async ({ requireWrites = true } = {}) => {
|
||||
if (!dbConfigured()) return { available: false, reason: "not-configured" };
|
||||
if (requireWrites && !writesAllowed()) return { available: false, reason: "writes-disabled" };
|
||||
|
||||
if (String(sshEnv("TUNNEL")).toLowerCase() === "true") {
|
||||
const tunnel = await ensureTunnel({
|
||||
sshHost: sshEnv("HOST"),
|
||||
sshPort: Number(sshEnv("PORT") || 22),
|
||||
sshUser: sshEnv("USER"),
|
||||
keyPath: sshEnv("KEY"),
|
||||
passphrase: sshEnv("PASSPHRASE"),
|
||||
agent: sshEnv("AGENT"),
|
||||
dbHost: env("HOST"),
|
||||
dbPort: Number(env("PORT") || 5432),
|
||||
});
|
||||
if (!tunnel.tunnelled) return { available: false, reason: `ssh-tunnel-failed: ${tunnel.reason}` };
|
||||
}
|
||||
|
||||
try {
|
||||
await withClient((client) => client.query("SELECT 1"));
|
||||
return { available: true };
|
||||
@@ -138,11 +118,7 @@ const closeDb = async () => {
|
||||
// drop the reference first: a later task must not get a pool that is ending
|
||||
const open = pool;
|
||||
pool = null;
|
||||
try {
|
||||
if (open) await open.end();
|
||||
} finally {
|
||||
await closeTunnel();
|
||||
}
|
||||
if (open) await open.end();
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* cy.task implementations for the Gesamtnoteneingabe fixture. Connection and guards come from
|
||||
* db.js; this file only knows which rows the §1 specs write and how to undo them.
|
||||
* db.js; this file only knows which rows the specs write and how to undo them.
|
||||
*
|
||||
* Needed because the Noten API has no delete endpoint, while every §1 test writes
|
||||
* Needed because the Noten API has no delete endpoint, while every test writes
|
||||
* campus.tbl_lvgesamtnote and lehre.tbl_pruefung. Without a reset the suite is single-shot.
|
||||
*
|
||||
* Every statement is scoped by an explicit student_uid list plus the test LV and semester.
|
||||
@@ -136,11 +136,11 @@ const seedPruefung = async (scope) => {
|
||||
assertWritable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const { lvId, semKurzbz, studentUid, lehreinheitId, note, datum, typ, mitarbeiterUid } = scope;
|
||||
const { lvId, semKurzbz, studentUid, lehreinheitId, note, datum, type, mitarbeiterUid } = scope;
|
||||
|
||||
if (note === undefined || note === null) throw new Error("noten:db seedPruefung - note is required");
|
||||
if (!datum) throw new Error("noten:db seedPruefung - datum is required");
|
||||
if (!typ) throw new Error("noten:db seedPruefung - typ is required");
|
||||
if (!type) throw new Error("noten:db seedPruefung - type is required");
|
||||
if (!lehreinheitId) throw new Error("noten:db seedPruefung - lehreinheitId is required");
|
||||
if (!mitarbeiterUid) throw new Error("noten:db seedPruefung - mitarbeiterUid is required");
|
||||
|
||||
@@ -163,10 +163,10 @@ const seedPruefung = async (scope) => {
|
||||
anmerkung, insertamum, insertvon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '', NOW(), $3)
|
||||
RETURNING pruefung_id`,
|
||||
[lehreinheitId, studentUid, mitarbeiterUid, note, typ, datum],
|
||||
[lehreinheitId, studentUid, mitarbeiterUid, note, type, datum],
|
||||
);
|
||||
|
||||
return { pruefungId: res.rows[0].pruefung_id, note, datum, typ };
|
||||
return { pruefungId: res.rows[0].pruefung_id, note, datum, type };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ const assertReadable = () => {
|
||||
const readRows = async (sql, params) => withClient(async (client) => (await client.query(sql, params)).rows);
|
||||
|
||||
/** Same query as Lehrveranstaltung_model::getLeIdsByStudent. Empty = no participant. */
|
||||
const lehreinheitenDesStudenten = async (scope) => {
|
||||
const lehreinheitenOfStudent = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
@@ -239,7 +239,7 @@ const lehreinheitenDesStudenten = async (scope) => {
|
||||
};
|
||||
|
||||
/** Same joins as Lehreinheit_model::getLehreinheitenForLv. */
|
||||
const lehreinheitenDerLv = async (scope) => {
|
||||
const lehreinheitenOfLv = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope(scope, LV_SCOPE);
|
||||
|
||||
@@ -255,7 +255,7 @@ const lehreinheitenDerLv = async (scope) => {
|
||||
};
|
||||
|
||||
/** A Lehreinheit of another LV in the same semester, or null. */
|
||||
const fremdeLehreinheit = async (scope) => {
|
||||
const foreignLehreinheit = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope(scope, LV_SCOPE);
|
||||
|
||||
@@ -269,7 +269,7 @@ const fremdeLehreinheit = async (scope) => {
|
||||
};
|
||||
|
||||
/** An active student without any Lehreinheit in the LV, or null. */
|
||||
const nichtTeilnehmer = async (scope) => {
|
||||
const nonParticipant = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope(scope, LV_SCOPE);
|
||||
|
||||
@@ -286,23 +286,23 @@ const nichtTeilnehmer = async (scope) => {
|
||||
};
|
||||
|
||||
/** LV notes of the student in OTHER LVs of the semester; resetStudents never reaches them. */
|
||||
const andereLvNoten = async (scope) => {
|
||||
const otherLvNoten = async (scope) => {
|
||||
assertReadable();
|
||||
assertScope({ ...scope, studentUids: [scope.studentUid] });
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT count(*)::int AS anzahl FROM campus.tbl_lvgesamtnote
|
||||
`SELECT count(*)::int AS total FROM campus.tbl_lvgesamtnote
|
||||
WHERE student_uid = $1 AND studiensemester_kurzbz = $2 AND lehrveranstaltung_id <> $3`,
|
||||
[scope.studentUid, scope.semKurzbz, scope.lvId],
|
||||
);
|
||||
return rows[0].anzahl;
|
||||
return rows[0].total;
|
||||
};
|
||||
|
||||
const pruefungstypVorhanden = async ({ typ } = {}) => {
|
||||
const pruefungstypExists = async ({ type } = {}) => {
|
||||
assertReadable();
|
||||
if (!typ) throw new Error("noten:db pruefungstypVorhanden - typ is required");
|
||||
if (!type) throw new Error("noten:db pruefungstypExists - type is required");
|
||||
|
||||
const rows = await readRows("SELECT 1 FROM lehre.tbl_pruefungstyp WHERE pruefungstyp_kurzbz = $1", [typ]);
|
||||
const rows = await readRows("SELECT 1 FROM lehre.tbl_pruefungstyp WHERE pruefungstyp_kurzbz = $1", [type]);
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
@@ -335,9 +335,9 @@ const vorlagen = async ({ kurzbz } = {}) => {
|
||||
};
|
||||
|
||||
/** Lektoren of a Lehreinheit, sorted by uid like Noten::lehrendeDerLehreinheit (strcmp). */
|
||||
const lehrendeDerLehreinheit = async ({ lehreinheitId } = {}) => {
|
||||
const lehrendeOfLehreinheit = async ({ lehreinheitId } = {}) => {
|
||||
assertReadable();
|
||||
if (!lehreinheitId) throw new Error("noten:db lehrendeDerLehreinheit - lehreinheitId is required");
|
||||
if (!lehreinheitId) throw new Error("noten:db lehrendeOfLehreinheit - lehreinheitId is required");
|
||||
|
||||
const rows = await readRows(
|
||||
`SELECT mitarbeiter_uid FROM lehre.tbl_lehreinheitmitarbeiter
|
||||
@@ -368,15 +368,15 @@ const registerNotenDbTasks = (on) => {
|
||||
"noten:db:seedPruefung": (scope) => seedPruefung(scope),
|
||||
"noten:db:seedZeugnisnote": (scope) => seedZeugnisnote(scope),
|
||||
"noten:db:readLvGesamtnote": (scope) => readLvGesamtnote(scope),
|
||||
"noten:db:lehreinheitenDesStudenten": (scope) => lehreinheitenDesStudenten(scope),
|
||||
"noten:db:lehreinheitenDerLv": (scope) => lehreinheitenDerLv(scope),
|
||||
"noten:db:fremdeLehreinheit": (scope) => fremdeLehreinheit(scope),
|
||||
"noten:db:nichtTeilnehmer": (scope) => nichtTeilnehmer(scope),
|
||||
"noten:db:andereLvNoten": (scope) => andereLvNoten(scope),
|
||||
"noten:db:pruefungstypVorhanden": (scope) => pruefungstypVorhanden(scope),
|
||||
"noten:db:lehreinheitenOfStudent": (scope) => lehreinheitenOfStudent(scope),
|
||||
"noten:db:lehreinheitenOfLv": (scope) => lehreinheitenOfLv(scope),
|
||||
"noten:db:foreignLehreinheit": (scope) => foreignLehreinheit(scope),
|
||||
"noten:db:nonParticipant": (scope) => nonParticipant(scope),
|
||||
"noten:db:otherLvNoten": (scope) => otherLvNoten(scope),
|
||||
"noten:db:pruefungstypExists": (scope) => pruefungstypExists(scope),
|
||||
"noten:db:deletePruefung": (scope) => deletePruefung(scope),
|
||||
"noten:db:vorlagen": (scope) => vorlagen(scope),
|
||||
"noten:db:lehrendeDerLehreinheit": (scope) => lehrendeDerLehreinheit(scope),
|
||||
"noten:db:lehrendeOfLehreinheit": (scope) => lehrendeOfLehreinheit(scope),
|
||||
"noten:db:pruefungMitarbeiter": (scope) => pruefungMitarbeiter(scope),
|
||||
"noten:db:close": () => closeDb(),
|
||||
});
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
/**
|
||||
* Konfigurationsprofile der Benotungstool-Suite auf der Testinstanz.
|
||||
*
|
||||
* Ein Profil ändert application/config/noten.php und die define()-Schalter in
|
||||
* config/global.config.inc.php. Die Originale liegen im Home-Verzeichnis des SSH-Benutzers, also
|
||||
* ausserhalb des Webroots: global.config.inc.php enthält Zugangsdaten.
|
||||
*
|
||||
* zustand.json hält die md5 jeder geschriebenen Datei. Weicht eine Datei davon ab, ist sie seither neu
|
||||
* ausgerollt. Dann gilt sie als Original, und die alte Sicherung verfällt.
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
const { resolveAuth } = require("./sshTunnel");
|
||||
const { basis, profile } = require("../profiles/noten");
|
||||
|
||||
const PREFIX = process.env.TEST_ENV_PREFIX || "TEST";
|
||||
|
||||
// Die Instanz kann auf einem anderen Host liegen als der SSH-Tunnel zur Datenbank. Ist
|
||||
// <PREFIX>_PROFIL_SSH_HOST gesetzt, kommt jeder SSH-Wert aus <PREFIX>_PROFIL_SSH_*; ohne Schlüssel gilt
|
||||
// dann der SSH-Agent.
|
||||
const envWert = (key) => {
|
||||
const eigenerHost = key.startsWith("SSH_") && Boolean(process.env[`${PREFIX}_PROFIL_SSH_HOST`]);
|
||||
return process.env[`${PREFIX}_${eigenerHost ? "PROFIL_" : ""}${key}`];
|
||||
};
|
||||
|
||||
const DATEIEN = {
|
||||
config: "application/config/noten.php",
|
||||
flags: "config/global.config.inc.php",
|
||||
};
|
||||
|
||||
// relativ zum Home-Verzeichnis, dort beginnt jede SFTP-Sitzung
|
||||
const SICHERUNG = ".cypress-noten-profil";
|
||||
const ZUSTAND = `${SICHERUNG}/zustand.json`;
|
||||
|
||||
const NAME = /^[a-z0-9-]+$/;
|
||||
const SCHLUESSEL = /^[A-Z0-9_]+$/;
|
||||
|
||||
const md5 = (inhalt) => crypto.createHash("md5").update(inhalt).digest("hex");
|
||||
|
||||
const sftpAufruf = (fn) => new Promise((resolve, reject) => fn((err, wert) => (err ? reject(err) : resolve(wert))));
|
||||
|
||||
const sshKonfiguriert = () => Boolean(envWert("SSH_HOST") && envWert("SSH_USER"));
|
||||
|
||||
// --- SFTP ---
|
||||
|
||||
const verbinde = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const { Client } = require("ssh2");
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = resolveAuth({
|
||||
keyPath: envWert("SSH_KEY"),
|
||||
passphrase: envWert("SSH_PASSPHRASE"),
|
||||
agent: envWert("SSH_AGENT"),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
client
|
||||
.on("ready", () => client.sftp((err, sftp) => (err ? reject(err) : resolve({ client, sftp }))))
|
||||
.on("error", (err) =>
|
||||
reject(new Error(`SSH zu ${envWert("SSH_USER")}@${envWert("SSH_HOST")} gescheitert: ${err.message}`)),
|
||||
)
|
||||
.connect({
|
||||
host: envWert("SSH_HOST"),
|
||||
port: Number(envWert("SSH_PORT") || 22),
|
||||
username: envWert("SSH_USER"),
|
||||
privateKey: auth.privateKey,
|
||||
passphrase: auth.passphrase,
|
||||
agent: auth.agent,
|
||||
readyTimeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
const mitSftp = async (fn) => {
|
||||
const { client, sftp } = await verbinde();
|
||||
try {
|
||||
return await fn(sftp);
|
||||
} finally {
|
||||
client.end();
|
||||
}
|
||||
};
|
||||
|
||||
const existiert = (sftp, datei) => new Promise((resolve) => sftp.stat(datei, (err) => resolve(!err)));
|
||||
const lies = (sftp, datei) => sftpAufruf((cb) => sftp.readFile(datei, cb));
|
||||
const schreibeRoh = (sftp, datei, inhalt, mode) => sftpAufruf((cb) => sftp.writeFile(datei, inhalt, { mode }, cb));
|
||||
|
||||
/** Schreibt über eine Temporärdatei, damit kein Request eine halbe Konfiguration liest. */
|
||||
const schreibe = async (sftp, datei, inhalt) => {
|
||||
// .php: ein Webserver führt die Datei aus, statt ihre Zugangsdaten auszuliefern
|
||||
const temp = `${datei}.cypress-temp.php`;
|
||||
await schreibeRoh(sftp, temp, inhalt, 0o644);
|
||||
await sftpAufruf((cb) => sftp.ext_openssh_rename(temp, datei, cb));
|
||||
};
|
||||
|
||||
const sicherungVon = (entfernt) => `${SICHERUNG}/${path.posix.basename(entfernt)}`;
|
||||
|
||||
const leseZustand = async (sftp) =>
|
||||
(await existiert(sftp, ZUSTAND)) ? JSON.parse((await lies(sftp, ZUSTAND)).toString("utf8")) : null;
|
||||
|
||||
const schreibeZustand = (sftp, zustand) => schreibeRoh(sftp, ZUSTAND, JSON.stringify(zustand, null, 2), 0o600);
|
||||
|
||||
// --- Umbau der Dateien ---
|
||||
|
||||
const phpString = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
||||
|
||||
const phpWert = (wert) => {
|
||||
if (wert === null) return "null";
|
||||
if (typeof wert === "boolean" || typeof wert === "number") return String(wert);
|
||||
if (typeof wert === "string") return phpString(wert);
|
||||
throw new Error(`Ein define()-Schalter nimmt nur einen einfachen Wert, nicht ${JSON.stringify(wert)}.`);
|
||||
};
|
||||
|
||||
// nur ASCII: der Block ändert die Kodierung der Datei nicht
|
||||
const asciiJson = (wert) =>
|
||||
JSON.stringify(wert).replace(/[-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
||||
|
||||
/** Hängt die Schlüssel des Profils an noten.php. Die spätere Zuweisung gewinnt. */
|
||||
const mitConfig = (original, name, config) => {
|
||||
const text = original.toString("latin1");
|
||||
const block =
|
||||
`\n// cypress-profil "${name}": nur auf der Testinstanz, die Suite stellt das Original wieder her\n` +
|
||||
`$config = array_replace($config, json_decode(${phpString(asciiJson(config))}, true));\n`;
|
||||
const ende = text.search(/\?>\s*$/);
|
||||
|
||||
return Buffer.from(ende === -1 ? text + block : text.slice(0, ende) + block + text.slice(ende), "latin1");
|
||||
};
|
||||
|
||||
/** Ersetzt die define()-Zeile jedes Schalters. Ein zweites define() druckt eine Notice in die Seite. */
|
||||
const mitFlags = (original, flags) => {
|
||||
let text = original.toString("latin1");
|
||||
|
||||
Object.entries(flags).forEach(([flag, wert]) => {
|
||||
const muster = new RegExp(`^([ \\t]*)define\\(\\s*['"]${flag}['"]\\s*,[^;]*;`, "m");
|
||||
if (!muster.test(text)) {
|
||||
throw new Error(
|
||||
`${DATEIEN.flags} definiert ${flag} nicht. Ein Profil ändert nur einen bestehenden Schalter.`,
|
||||
);
|
||||
}
|
||||
text = text.replace(muster, (treffer, einzug) => `${einzug}define('${flag}', ${phpWert(wert)});`);
|
||||
});
|
||||
|
||||
return Buffer.from(text, "latin1");
|
||||
};
|
||||
|
||||
// --- Profile ---
|
||||
|
||||
/** Profil samt Basis. pruefe nennt Werte aus getCisConfig, die ein Flag nur mittelbar ändert. */
|
||||
const profilDaten = (name) => {
|
||||
if (!NAME.test(String(name)) || !Object.prototype.hasOwnProperty.call(profile, name)) {
|
||||
throw new Error(`Unbekanntes Profil "${name}". Vorhanden: ${Object.keys(profile).join(", ")}`);
|
||||
}
|
||||
|
||||
const daten = {
|
||||
config: { ...basis.config, ...profile[name].config },
|
||||
flags: { ...basis.flags, ...profile[name].flags },
|
||||
pruefe: { ...profile[name].pruefe },
|
||||
};
|
||||
|
||||
[...Object.keys(daten.config), ...Object.keys(daten.flags)].forEach((key) => {
|
||||
if (!SCHLUESSEL.test(key)) throw new Error(`Ungültiger Schlüssel "${key}" in Profil "${name}".`);
|
||||
});
|
||||
|
||||
return daten;
|
||||
};
|
||||
|
||||
/** Stellt eine Datei her, die ein Profil geschrieben hat. Eine neu ausgerollte Datei bleibt stehen. */
|
||||
const stelleHer = async (sftp, zustand, schluessel) => {
|
||||
const eintrag = zustand.dateien[schluessel];
|
||||
if (!eintrag) return;
|
||||
|
||||
const entfernt = `${zustand.root}/${DATEIEN[schluessel]}`;
|
||||
const sicherung = sicherungVon(entfernt);
|
||||
|
||||
if (await existiert(sftp, sicherung)) {
|
||||
if (md5(await lies(sftp, entfernt)) === eintrag.md5) {
|
||||
await schreibe(sftp, entfernt, await lies(sftp, sicherung));
|
||||
} else {
|
||||
console.warn(`[profil] ${entfernt} ist seit dem Profil neu ausgerollt und bleibt stehen.`);
|
||||
}
|
||||
await sftpAufruf((cb) => sftp.unlink(sicherung, cb));
|
||||
}
|
||||
|
||||
delete zustand.dateien[schluessel];
|
||||
await schreibeZustand(sftp, zustand);
|
||||
};
|
||||
|
||||
/** Schreibt eine Datei nach dem Profil. Die Grundlage ist immer das Original, nie ein anderes Profil. */
|
||||
const setze = async (sftp, zustand, schluessel, umbau) => {
|
||||
const entfernt = `${zustand.root}/${DATEIEN[schluessel]}`;
|
||||
const sicherung = sicherungVon(entfernt);
|
||||
const aktuell = await lies(sftp, entfernt);
|
||||
const eintrag = zustand.dateien[schluessel];
|
||||
const vomProfil = Boolean(eintrag) && md5(aktuell) === eintrag.md5;
|
||||
|
||||
let original = aktuell;
|
||||
if (vomProfil) {
|
||||
if (!(await existiert(sftp, sicherung))) {
|
||||
throw new Error(`Die Sicherung ${sicherung} fehlt. Das Original von ${entfernt} ist unbekannt.`);
|
||||
}
|
||||
original = await lies(sftp, sicherung);
|
||||
} else {
|
||||
if (eintrag)
|
||||
console.warn(`[profil] ${entfernt} ist seit dem letzten Profil neu ausgerollt und gilt als Original.`);
|
||||
await schreibeRoh(sftp, sicherung, aktuell, 0o600);
|
||||
}
|
||||
|
||||
const neu = umbau(original);
|
||||
|
||||
// zuerst der Zustand: bricht das Schreiben ab, gilt die Datei beim nächsten Lauf als Original
|
||||
zustand.dateien[schluessel] = { md5: md5(neu) };
|
||||
await schreibeZustand(sftp, zustand);
|
||||
await schreibe(sftp, entfernt, neu);
|
||||
};
|
||||
|
||||
const gleich = (ist, soll) =>
|
||||
typeof soll === "boolean" ? Boolean(ist) === soll : JSON.stringify(ist) === JSON.stringify(soll);
|
||||
|
||||
/** Die Daten von getCisConfig, oder null mit Grund bei einem vorübergehenden Fehler (Netz, HTTP 5xx). */
|
||||
const leseCisConfig = async (url, auth) => {
|
||||
let antwort;
|
||||
try {
|
||||
antwort = await fetch(url, { headers: { Authorization: auth } });
|
||||
} catch (error) {
|
||||
return { ist: null, grund: error.message };
|
||||
}
|
||||
|
||||
const text = await antwort.text();
|
||||
try {
|
||||
const ist = JSON.parse(text).data;
|
||||
if (ist) return { ist };
|
||||
} catch (e) {
|
||||
// keine JSON-Antwort
|
||||
}
|
||||
|
||||
// unter 500 steht eine PHP-Meldung vor dem JSON: die Datei ist kaputt, Warten hilft nicht
|
||||
if (antwort.status < 500) {
|
||||
throw new Error(
|
||||
`getCisConfig antwortet nach dem Profilwechsel mit HTTP ${antwort.status}: ${text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return { ist: null, grund: `getCisConfig antwortet mit HTTP ${antwort.status}` };
|
||||
};
|
||||
|
||||
/** Wartet, bis getCisConfig das Profil liefert. PHP-FPM liest eine geänderte Datei erst nach opcache.revalidate_freq. */
|
||||
const pruefeInstanz = async (daten) => {
|
||||
const erwartet = { ...daten.config, ...daten.flags, ...daten.pruefe };
|
||||
const basisUrl = String(process.env.BASE_URL).replace(/\/+$/, "");
|
||||
const url = `${basisUrl}/index.ci.php/api/frontend/v1/Noten/getCisConfig`;
|
||||
const auth = `Basic ${Buffer.from(`${process.env.USER_NAME}:${process.env.USER_PASSWORD}`).toString("base64")}`;
|
||||
const ende = Date.now() + 60000;
|
||||
|
||||
for (;;) {
|
||||
const { ist, grund } = await leseCisConfig(url, auth);
|
||||
let offen = grund;
|
||||
|
||||
if (ist) {
|
||||
const pruefbar = Object.keys(erwartet).filter((key) => key in ist);
|
||||
if (!pruefbar.length) {
|
||||
console.warn("[profil] getCisConfig liefert keinen Schlüssel des Profils. Der Wechsel ist ungeprüft.");
|
||||
return;
|
||||
}
|
||||
|
||||
const abweichend = pruefbar.filter((key) => !gleich(ist[key], erwartet[key]));
|
||||
if (!abweichend.length) return;
|
||||
|
||||
const liste = abweichend.map(
|
||||
(k) => ` ${k}: ${JSON.stringify(ist[k])} statt ${JSON.stringify(erwartet[k])}`,
|
||||
);
|
||||
offen = `die Instanz zeigt das Profil nicht:\n${liste.join("\n")}`;
|
||||
}
|
||||
|
||||
if (Date.now() > ende) throw new Error(`Profilwechsel nach 60 Sekunden ungeprüft, ${offen}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
};
|
||||
|
||||
// --- öffentlich ---
|
||||
|
||||
/** Nimmt das aktive Profil zurück. Liefert seinen Namen oder null. */
|
||||
const wiederherstellen = async () => {
|
||||
if (!sshKonfiguriert()) return null;
|
||||
|
||||
return mitSftp(async (sftp) => {
|
||||
const zustand = await leseZustand(sftp);
|
||||
if (!zustand || !Object.keys(zustand.dateien).length) return null;
|
||||
|
||||
const war = zustand.profil;
|
||||
for (const schluessel of Object.keys(DATEIEN)) await stelleHer(sftp, zustand, schluessel);
|
||||
|
||||
zustand.profil = null;
|
||||
await schreibeZustand(sftp, zustand);
|
||||
console.log(`[profil] "${war}" ist zurückgenommen.`);
|
||||
return war;
|
||||
});
|
||||
};
|
||||
|
||||
/** Schaltet die Instanz auf ein Profil. Ohne Namen stellt es die Originale her. */
|
||||
const anwenden = async (name) => {
|
||||
if (!name) return wiederherstellen();
|
||||
|
||||
const daten = profilDaten(name);
|
||||
const root = envWert("REMOTE_ROOT");
|
||||
if (!sshKonfiguriert() || !root) {
|
||||
throw new Error(
|
||||
`Profil "${name}" braucht ${PREFIX}_SSH_HOST, ${PREFIX}_SSH_USER und ${PREFIX}_REMOTE_ROOT in tests/cypress/.env.`,
|
||||
);
|
||||
}
|
||||
|
||||
await mitSftp(async (sftp) => {
|
||||
if (!(await existiert(sftp, SICHERUNG))) await sftpAufruf((cb) => sftp.mkdir(SICHERUNG, { mode: 0o700 }, cb));
|
||||
|
||||
const zustand = (await leseZustand(sftp)) || { dateien: {} };
|
||||
if (zustand.root && zustand.root !== root && Object.keys(zustand.dateien).length) {
|
||||
throw new Error(
|
||||
`Auf ${zustand.root} ist noch "${zustand.profil}" aktiv. Zuerst: npm run noten:profil -- wiederherstellen`,
|
||||
);
|
||||
}
|
||||
zustand.root = root;
|
||||
zustand.profil = name;
|
||||
|
||||
if (Object.keys(daten.config).length)
|
||||
await setze(sftp, zustand, "config", (o) => mitConfig(o, name, daten.config));
|
||||
else await stelleHer(sftp, zustand, "config");
|
||||
|
||||
if (Object.keys(daten.flags).length) await setze(sftp, zustand, "flags", (o) => mitFlags(o, daten.flags));
|
||||
else await stelleHer(sftp, zustand, "flags");
|
||||
|
||||
await schreibeZustand(sftp, zustand);
|
||||
});
|
||||
|
||||
await pruefeInstanz(daten);
|
||||
console.log(`[profil] "${name}" ist aktiv.`);
|
||||
return name;
|
||||
};
|
||||
|
||||
const status = () => (sshKonfiguriert() ? mitSftp(leseZustand) : Promise.resolve(null));
|
||||
|
||||
module.exports = { anwenden, wiederherstellen, status, profilDaten };
|
||||
@@ -102,7 +102,7 @@ module.exports = {
|
||||
sql: `SELECT COALESCE((SELECT note::text FROM lehre.tbl_note
|
||||
WHERE bezeichnung = 'Noch nicht eingetragen'), '') AS value`,
|
||||
ok: (r) => r.value !== "",
|
||||
hint: "Missing from the base dump. Rebuild with system/setup_testinstance.php --setup.",
|
||||
hint: "Missing from the base dump. Rebuild the database with db_setup.sh.",
|
||||
},
|
||||
{
|
||||
label: "Pruefungstypen Termin1/Termin2/Termin3/kommPruef",
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* node tests/cypress/tools/dbCheck.js noten seed apply the suite's seeder, then re-check
|
||||
* node tests/cypress/tools/dbCheck.js noten env print the .env block to use
|
||||
*
|
||||
* A suite contributes tools/checks/<suite>.js exporting { title, checks, sqlFiles }. Connection,
|
||||
* tunnel and output are shared and live here. A sqlFiles entry is a path or { file, group }; a
|
||||
* A suite contributes tools/checks/<suite>.js exporting { title, checks, sqlFiles }. Connection
|
||||
* and output are shared and live here. A sqlFiles entry is a path or { file, group }; a
|
||||
* "-- ==== <group> ====" line starts a group.
|
||||
*
|
||||
* `check` is read-only; `seed` needs <PREFIX>_DB_ALLOW_WRITES=true and an owner role.
|
||||
@@ -17,7 +17,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
try {
|
||||
require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
|
||||
require("dotenv").config({ path: path.join(__dirname, "..", "suites", ".env") });
|
||||
} catch (e) {
|
||||
// dotenv missing -> fall back to the ambient environment
|
||||
}
|
||||
@@ -116,7 +116,7 @@ const applySqlFile = async (client, entry) => {
|
||||
if (/permission denied/i.test(error.message)) {
|
||||
console.error(`${error.message}\n`);
|
||||
console.error(`Seeding needs the database owner, but ${db.PREFIX}_DB_USER is "${db.env("USER")}".`);
|
||||
console.error("Point it at the owner role, or seed via system/setup_testinstance.php.\n");
|
||||
console.error("Point it at the owner role, or rebuild the database with db_setup.sh.\n");
|
||||
return 2;
|
||||
}
|
||||
throw error;
|
||||
@@ -137,7 +137,7 @@ const applySqlFile = async (client, entry) => {
|
||||
const printEnv = () => {
|
||||
const p = db.PREFIX;
|
||||
console.log(`
|
||||
Add to tests/cypress/.env (gitignored):
|
||||
Add to tests/cypress/suites/.env (gitignored):
|
||||
|
||||
TEST_ENV_PREFIX=${p}
|
||||
${p}_DB_HOST=<postgres host>
|
||||
@@ -146,13 +146,6 @@ Add to tests/cypress/.env (gitignored):
|
||||
${p}_DB_USER=<user>
|
||||
${p}_DB_PASSWORD=<password>
|
||||
${p}_DB_ALLOW_WRITES=true
|
||||
|
||||
If pg_hba.conf does not admit this machine, add the tunnel:
|
||||
|
||||
${p}_SSH_TUNNEL=true
|
||||
${p}_SSH_HOST=<ssh host, often NOT the web hostname>
|
||||
${p}_SSH_USER=<user>
|
||||
${p}_SSH_KEY=<path, or empty to use the ssh agent>
|
||||
`);
|
||||
};
|
||||
|
||||
@@ -181,7 +174,6 @@ const main = async () => {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Opens the SSH tunnel when configured, so a workstation run needs no manual forward.
|
||||
const status = await db.checkAvailability({ requireWrites: mode !== "check" });
|
||||
if (!status.available) {
|
||||
console.error(`\nCould not connect: ${status.reason}\n`);
|
||||
|
||||
Reference in New Issue
Block a user