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:
Johann Hoffmann
2026-09-21 16:59:31 +02:00
parent 73c63c791d
commit c1939d0e23
55 changed files with 3500 additions and 1486 deletions
+2 -21
View File
@@ -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
View File
@@ -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"
}
}
+12
View File
@@ -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
+40
View File
@@ -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 ------------------------------------------------------------------------------------
);
+3 -68
View File
@@ -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);
+33 -23
View File
@@ -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;
});
+25
View File
@@ -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
+87
View File
@@ -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,
];
+163
View File
@@ -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);
});
+25
View File
@@ -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"
}
}
+344
View File
@@ -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 };
+137
View File
@@ -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 };
+79
View File
@@ -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 };
+46 -47
View File
@@ -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,
+38 -31
View File
@@ -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;
},
};
+55 -103
View File
@@ -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.
+22 -15
View File
@@ -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];
+3 -4
View File
@@ -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,
});
+40 -40
View File
@@ -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);
});
+43 -43
View File
@@ -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.");
+3 -3
View File
@@ -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 = {
+7 -8
View File
@@ -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
+39 -46
View File
@@ -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);
+98 -96
View File
@@ -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();
+7 -31
View File
@@ -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;
};
+25 -25
View File
@@ -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(),
});
-344
View File
@@ -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 };
+1 -1
View File
@@ -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",
+5 -13
View File
@@ -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`);