WIP refactor

This commit is contained in:
Johann Hoffmann
2026-08-06 10:33:46 +02:00
parent 56fcb23275
commit c9c92f82d3
17 changed files with 1715 additions and 1667 deletions
+33 -4
View File
@@ -2,22 +2,51 @@
if (!defined('BASEPATH')) exit('No direct script access allowed');
// 'entschuldigt' & 'noch nicht eingetragen' -> wirken sich nicht auf Antritte aus
// Noten die keinen Prüfungsantritt verbrauchen. Fallback-PKs; massgeblich sind die per Bezeichnung
// aufgelösten Noten (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN), da die PKs je Installation abweichen.
$config['NOTEN_OHNE_ANTRITT'] = [9, 17]; // tbl_note pk
$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // across the 4 fixed antritte only one can be entschuldigt
// 'Nicht beurteilt' ist eine Altlast: formal eine Note, inhaltlich keine Leistungsfeststellung.
// Wird weiterhin häufig vergeben und darf daher keinen Antritt kosten.
$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt', 'Nicht beurteilt'];
// Anrechnungsnoten: die Leistung wurde vorab anerkannt, der/die Studierende ist zwar Teil der
// Lehrveranstaltung, tritt aber zu keiner Prüfung an. Solange die ZEUGNISNOTE eine davon ist, sind
// für diese Lehrveranstaltung keine Prüfungen möglich. Auflösung wie oben über die Bezeichnung,
// die PKs sind nur Fallback.
$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet'];
$config['NOTEN_ANRECHNUNG'] = [6, 16]; // tbl_note pk
$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // über alle Antritte hinweg nur ein entschuldigt
// tbl_note pk of the 'entschuldigt' note. An entschuldigt Termin is preserved as its own dated
// entry when a new pruefung of the same type is created (instead of being overwritten).
$config['NOTE_ENTSCHULDIGT'] = 17;
// Maximale Anzahl zählender Prüfungsantritte, die IN DIESEM TOOL eingetragen werden können
// (inkl. erstem Antritt). null = aus den Alt-Flags CIS_GESAMTNOTE_PRUEFUNG_TERMIN2/TERMIN3
// ableiten (1 + je aktiviertem Termin), damit bestehende Installationen unverändert laufen.
// Der abschliessende Antritt (kommPruef) wird woanders eingetragen und zählt hier nicht mit.
$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null;
// Prüfungstypen die den Verlauf abschliessen: danach ist kein weiterer Antritt möglich. Das ist
// die einzige Stelle, an der der Typ eines Termins noch eine Bedeutung trägt.
$config['PRUEFUNG_TERMINAL_TYPEN'] = ['kommPruef', 'zusKommPruef'];
// Spaltenaufteilung der Prüfungen in der Notentabelle:
// 'antritt' - eine Spalte je Antrittsnummer, Datum steht in der Zelle (robust bei Einzelterminen)
// 'datum' - eine Spalte je Prüfungsdatum (kompakt, wenn ganze Jahrgänge am selben Tag antreten)
// Nur die Vorgabe; im Tool umschaltbar und pro Benutzer gespeichert.
$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt';
// Bei der Notenübernahme durch die Assistenz den ersten Antritt als eigene Prüfung anlegen.
$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true;
// availability of the two Benotungstool import flows. When both are true they are shown as
// separate buttons/dialogs.
$config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // dated import that creates a pruefung per row
$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date)
$config['SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'] = true;
// Noteneintragungsfrist (Prüfungsordnung §1): grade/pruefung entry is only permitted up to this
// deadline. The month/day below is applied to the studiensemester's year:
// Sommersemester (SSyyyy) -> deadline in the SAME calendar year (default 15th November)
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller
// Load Libraries
$this->load->library('VariableLib', ['uid' => getAuthUID()]);
$this->load->library('PruefungsverlaufLib', null, 'VerlaufLib');
$this->load->config('noten');
// Load Phrases
$this->loadPhrases([
@@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller
$data['lehrveranstaltung_id'] = $lehrveranstaltung_id;
$data['student_uid'] = $student_uid;
$data['studiensemester_kurzbz'] = $studiensemester_kurzbz;
$this->ZeugnisnoteModel->insert($data);
if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN')
&& FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) {
$result = $this->addTestsForGrade(
$studiensemester_kurzbz,
$student_uid,
$lehrveranstaltung_id,
$teacherGrade->note,
$teacherGrade->punkte
);
$this->getDataOrTerminateWithError($result);
}
$this->ZeugnisnoteModel->insert($data);
}
// Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der
// Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim
// Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim
// Überschreiben einer bestehenden Zeugnisnote) und ist idempotent.
if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) {
$result = $this->addErstantrittForGrade(
$studiensemester_kurzbz,
$student_uid,
$lehrveranstaltung_id,
$teacherGrade
);
$this->getDataOrTerminateWithError($result);
}
$this->terminateWithSuccess(true);
}
@@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller
}
/**
* Helper function that adds tests for a student
* (Entries in lehre.tbl_pruefung)
* Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an
* (Eintrag in lehre.tbl_pruefung).
*
* @param string $studiensemester_kurzbz
* @param string $student_uid
* @param integer $lehrveranstaltung_id
* @param integer $note
* @param numeric $punkte
* Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts -
* die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen.
*
* Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die
* Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur,
* der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum.
*
* @param string $studiensemester_kurzbz
* @param string $student_uid
* @param integer $lehrveranstaltung_id
* @param stdClass $teacherGrade die übernommene LV-Gesamtnote
*
* @return stdClass
*/
protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte)
protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade)
{
$this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel');
$this->load->model('education/LePruefung_model', 'LePruefungModel');
// bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen
$vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz);
if (count($vorhanden) > 0)
return success(null);
// Get Lehreinheit
$result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id);
@@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller
return error($this->p->t("stv", "grades_error_lehreinheit_id"));
$le = current(getData($result));
$datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d'));
$now = date('Y-m-d H:i:s');
// Prepare
$this->load->model('education/LePruefung_model', 'LePruefungModel');
$data = [
"student_uid" => $student_uid,
"lehreinheit_id" => $le->lehreinheit_id,
"datum" => date('Y-m-d'),
"pruefungstyp_kurzbz" => "Termin1",
"note" => $note
"mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid,
"datum" => $datum,
"pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1),
"note" => $teacherGrade->note,
"insertamum" => $now,
"insertvon" => getAuthUID()
];
if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE)
$data["punkte"] = $punkte;
$data["punkte"] = $teacherGrade->punkte;
// Get Anwesenheit
$this->load->model('education/Anwesenheit_model', 'AnwesenheitModel');
$result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id);
@@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller
return $result;
$anwesenheit = getData($result);
if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN
&& $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) {
// Get Anwesenheitsbefreiung
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
$result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit');
@@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller
$anwesenheitsbefreit = hasData($result);
// Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen
// Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin
// 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note
// zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere
// "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation.
if (!$anwesenheitsbefreit) {
$data2 = $data;
$data2["note"] = 7;
@@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller
if (isError($result))
return $result;
$data["pruefungstyp_kurzbz"] = "Termin2";
}
}
@@ -55,7 +55,7 @@ class Lvgesamtnote_model extends DB_Model
$params = [$student_uid, $studiensemester_kurzbz];
if ($lehrveranstaltung_id) {
$qry .= "AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?";
$qry .= " AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?";
$params[] = $lehrveranstaltung_id;
}
@@ -1824,7 +1824,7 @@ $htmlstring .= "
</table>
";
if ($anzahlChanged > 0)
if (isset($anzahlChanged) && $anzahlChanged > 0)
echo '<div id="notenWarning" class="warning">' . $p->t('benotungstool/geaenderteNotenVorhanden') . '</div>';
echo $htmlstring;
+58 -2
View File
@@ -62,9 +62,63 @@
transform: translateY(-50%) rotate(180deg);
}
.pruefung-badge {
/*
* Prueufungszelle: feste Spuren fuer Note | Datum | Aktion.
* Vorher war das eine zentrierte Flex-Zeile, dadurch verschoben laengere Notenbezeichnungen
* ("Nicht Genuegend" vs "Genuegend") Datum und Button - die Zellen standen von Zeile zu Zeile
* nicht mehr untereinander. Mit festen Spurbreiten fluchten sie immer.
*/
.pruefung-cell {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | Aktion */
align-items: center;
column-gap: 0.5rem;
height: 100%;
padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */
padding-right: 6px;
text-align: left;
}
/* Spaltenmodus 'antritt': das Datum steht in der Zelle, nicht im Spaltenkopf */
.pruefung-cell.mit-datum {
grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | Datum | Aktion */
}
/*
* Zellen ohne Aktion (abschliessender Termin, wird in einem anderen Tool gepflegt): die Spur fuer
* den Button entfaellt, der Platz gehoert der Notenbezeichnung.
*/
.pruefung-cell.ohne-aktion {
grid-template-columns: minmax(0, 1fr); /* Note */
}
.pruefung-cell.ohne-aktion.mit-datum {
grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | Datum */
}
.pruefung-cell .pruefung-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pruefung-cell .pruefung-datum {
font-size: 0.75rem;
white-space: nowrap;
text-align: right;
opacity: 0.7;
}
/* immer in der letzten Spur, auch wenn Note und Datum fehlen (leere Zelle mit "Hinzufuegen") */
.pruefung-cell .pruefung-action {
grid-column: -2 / -1;
display: flex;
justify-content: flex-end;
}
.pruefung-cell .pruefung-action .btn {
white-space: nowrap;
}
.pruefung-badge::before {
@@ -87,4 +141,6 @@
.pruefung-badge.attempt-t1::before { background: #0072B2; }
.pruefung-badge.attempt-t2::before { background: #E69F00; }
.pruefung-badge.attempt-t3::before { background: #CC79A7; }
.pruefung-badge.attempt-k::before { background: #D55E00; }
.pruefung-badge.attempt-k::before { background: #D55E00; }
/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */
.pruefung-badge.attempt-none::before { background: #767676; }
+7 -4
View File
@@ -49,18 +49,21 @@ export default {
params: { lv_id, sem_kurzbz, student_uid, note, punkte }
};
},
saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null){
// kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem
// bestehenden Prüfungsverlauf des Studenten in dieser LV und diesem Semester ab
saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null){
return {
method: 'post',
url: '/api/frontend/v1/Noten/saveStudentPruefung',
params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id }
params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id }
};
},
createPruefungen(uids, datum, lva_id, sem_kurzbz){
// note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt
createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null){
return {
method: 'post',
url: '/api/frontend/v1/Noten/createPruefungen',
params: { uids, datum, lva_id, sem_kurzbz }
params: { uids, datum, lva_id, sem_kurzbz, note, punkte }
};
},
saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) {
File diff suppressed because it is too large Load Diff
@@ -1,46 +1,51 @@
/**
* Prüfungsordnung §1 rules as the client applies them. Extracted from the Vue component so they are
* testable, and so the server-side mirrors (Noten.php: computeAntrittCount / computeMaxAntritte /
* getPruefungstypForStudentByAntritt) have something to be compared against.
* Prüfungsordnung §1 rules as the client applies them.
*
* Die Regeln selbst liegen im Server (PruefungsverlaufLib): er liefert je Student einen `verlauf`
* mit Antrittszahl, Grenze und der nächsten möglichen Rolle, und je Termin dessen Position sowie
* ob er einen Antritt verbraucht. Hier wird das nur noch ausgewertet - damit gibt es keine zweite
* Implementierung der Regeln mehr, die auseinanderlaufen kann.
*
* Der lokale Fallback greift nur, solange der Verlauf für eine Zeile noch nicht geladen ist.
*/
/** Enabled in-tool retake types, in attempt order. Termin1 is the original note, kommPruef terminal. */
export const retakeTypes = (config) => {
const types = [];
if (config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) types.push("Termin2");
if (config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) types.push("Termin3");
return types;
};
/** Original note (always 1) plus each enabled retake. kommPruef is entered elsewhere and not capped here. */
export const maxAntrittCount = (config) => 1 + retakeTypes(config).length;
/** Maximale Anzahl zählender Antritte in diesem Tool (serverseitig abgeleitet). */
export const maxAntrittCount = (config) => config?.CIS_GESAMTNOTE_MAX_ANTRITTE ?? 1;
/**
* Counting Prüfungsantritte. Counted by NOTE, not by typ: notes in NOTEN_OHNE_ANTRITT (entschuldigt,
* noch nicht eingetragen) never count, which is what lets Termin2 be entered twice. A kommPruef is
* terminal and returns a sentinel >= any maxAntrittCount. With no counting pruefung the original LV
* note is the first Antritt, unless it is a non-lehre note ("angerechnet") meaning no participation.
* Zählende Prüfungsantritte. Gezählt wird über die NOTE, nicht über den Termintyp: Noten in
* NOTEN_OHNE_ANTRITT (entschuldigt, nicht beurteilt, noch nicht eingetragen) verbrauchen keinen
* Antritt. Ohne zählenden Termin gilt die LV-Note selbst als erster Antritt, sofern sie zählt.
*/
export const antrittCountStudent = (student, config, notenOptions) => {
if (student["kommPruef"]) return 4;
if (student.verlauf) return student.verlauf.antrittCount;
const ohneAntritt = config.NOTEN_OHNE_ANTRITT;
const ohneAntritt = config?.NOTEN_OHNE_ANTRITT ?? [];
let count = 0;
for (const p of student.pruefungen) {
for (const p of student.pruefungen ?? []) {
if (!ohneAntritt.find((pk) => pk == p.note)) count++;
}
if (count === 0 && student.note) {
const noteOption = notenOptions.find((n) => n.note == student.note);
return noteOption.lehre ? 1 : 0;
if (count === 0 && student.note && !ohneAntritt.find((pk) => pk == student.note)) {
const noteOption = notenOptions?.find((n) => n.note == student.note);
return noteOption?.lehre ? 1 : 0;
}
return count;
};
/** Next retake type to offer. Attempt 1 is the original note, so retake n sits at index n-1. */
export const pruefungstypForAntritt = (student, config) =>
retakeTypes(config)[Math.max(0, student.hoechsterAntritt - 1)] ?? "";
/** Ob für den Studenten überhaupt noch ein Antritt angelegt werden darf. */
export const canAddPruefung = (student, config) => {
if (student.verlauf) return student.verlauf.canAdd;
return !student.kommPruef && antrittCountStudent(student, config) < maxAntrittCount(config);
};
/**
* Ob mit der neuen Prüfung auch erst die LV-Note entsteht. Kommt aus dem Verlauf und nicht aus
* `lv_note`: letzteres kennt nur FREIGEGEBENE Noten, eine erfasste aber nicht freigegebene LV-Note
* existiert trotzdem. Nur für den Hinweis in der Oberfläche.
*/
export const brauchtNeueLvNote = (student) => student.verlauf ? !student.verlauf.hatLvNote : !student.lv_note;
/** Grade state from the two timestamps. */
export const checkFreigabe = (freigabedatum, benotungsdatum) => {
+160
View File
@@ -62167,6 +62167,166 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4antrittNr',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{0}. Antritt',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Attempt {0}',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4spaltenAntritt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Antritte',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Attempts',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4spaltenAntrittHint',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Eine Spalte je Antritt, das Prüfungsdatum steht in der Zelle.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'One column per attempt, the exam date is shown in the cell.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4spaltenDatum',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Datum',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Date',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4spaltenDatumHint',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Eine Spalte je Prüfungsdatum.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'One column per exam date.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4lvNoteWirdAngelegtHinweis',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Es ist noch keine LV-Note eingetragen. Sie wird mit der Note dieser Prüfung angelegt und kann anschließend wie gewohnt bearbeitet und freigegeben werden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'There is no course grade yet. It will be created from the grade of this exam and can then be edited and approved as usual.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4angerechnetKeinePruefung',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Für {0} ist die Lehrveranstaltung angerechnet. Dafür können keine Prüfungen eingetragen werden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The course is credited for {0}. No exams can be recorded for it.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4pruefungNichtGespeichert',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Prüfung für {0} konnte nicht gespeichert werden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The exam for {0} could not be saved.',
'description' => '',
'insertvon' => 'system'
)
)
),
// CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------
);
@@ -22,8 +22,7 @@ import {
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
attemptsOfTyp,
enabledRetakeTypes,
attemptsOfStudent,
readState,
} from "../../../../support/helpers/notenScenario";
@@ -41,30 +40,28 @@ describe("Noten API - bulk paths", () => {
it("rejects only the row that breaks a §1 rule and accepts the rest", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
const retakes = enabledRetakeTypes(ctx);
resetNotenState(ctx);
seedBaseline(ctx, atCap.uid, { freigegeben: true });
seedBaseline(ctx, fresh.uid, { freigegeben: true });
// drive the first student up to the cap
retakes.forEach((typ, i) => {
// drive the first student up to the cap - the baseline already provides Antritt 1
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, atCap, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, i + 1),
typ,
}).then((response) => {
expectNotenSuccess(response, `bring ${atCap.uid} to the cap via ${typ}`);
expectNotenSuccess(response, `bring ${atCap.uid} to the cap (attempt ${i + 2})`);
});
});
}
const datum = attemptDate(ctx, retakes.length + 2);
const datum = attemptDate(ctx, ctx.maxAntritte + 1);
notenApi
.createPruefungen(
[
{ uid: atCap.uid, typ: retakes[retakes.length - 1], lehreinheit_id: atCap.lehreinheit_id },
{ uid: fresh.uid, typ: "Termin2", lehreinheit_id: fresh.lehreinheit_id },
{ uid: atCap.uid, lehreinheit_id: atCap.lehreinheit_id },
{ uid: fresh.uid, lehreinheit_id: fresh.lehreinheit_id },
],
datum,
ctx.lvId,
@@ -79,7 +76,7 @@ describe("Noten API - bulk paths", () => {
});
readState(ctx).then((data) => {
const created = attemptsOfTyp(data, fresh.uid, "Termin2");
const created = attemptsOfStudent(data, fresh.uid);
expect(
created.map((p) => String(p.datum).slice(0, 10)),
"the accepted row was really created",
@@ -97,14 +94,13 @@ describe("Noten API - bulk paths", () => {
addPruefung(ctx, student, {
note: ctx.notes.nochNichtEingetragen,
datum: attemptDate(ctx, 3),
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, "seed an open Termin2");
expectNotenSuccess(response, "seed an open attempt");
});
notenApi
.createPruefungen(
[{ uid: student.uid, typ: "Termin2", lehreinheit_id: student.lehreinheit_id }],
[{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
attemptDate(ctx, 2), // earlier than the existing attempt
ctx.lvId,
ctx.semKurzbz,
@@ -135,27 +131,21 @@ describe("Noten API - bulk paths", () => {
it("applies the §1 rules per row", () => {
const atCap = ctx.students[0];
const fresh = ctx.students[1];
const retakes = enabledRetakeTypes(ctx);
resetNotenState(ctx);
seedBaseline(ctx, atCap.uid, { freigegeben: true });
seedBaseline(ctx, fresh.uid, { freigegeben: true });
retakes.forEach((typ, i) => {
addPruefung(ctx, atCap, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, i + 1),
typ,
});
});
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, atCap, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, i + 1) });
}
const datum = attemptDate(ctx, retakes.length + 2);
const datum = attemptDate(ctx, ctx.maxAntritte + 1);
notenApi
.savePruefungenBulk(ctx.lvId, ctx.semKurzbz, [
{
uid: atCap.uid,
typ: retakes[retakes.length - 1],
note: ctx.gradeNotes[0],
punkte: null,
datum,
@@ -163,7 +153,6 @@ describe("Noten API - bulk paths", () => {
},
{
uid: fresh.uid,
typ: "Termin2",
note: ctx.gradeNotes[0],
punkte: null,
datum,
@@ -59,8 +59,7 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
lva_id: ctx.lvId,
lehreinheit_id: ctx.students[0].lehreinheit_id,
sem_kurzbz: sem,
typ: "Termin2",
pruefung_id: null,
pruefung_id: null,
})
.then((response) => {
expectNotenError(response, "noteneintragungsfristVorbei");
@@ -109,7 +108,6 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => {
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 1),
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, `grade entry within the deadline for ${ctx.semKurzbz}`);
});
@@ -1,12 +1,12 @@
/**
* Prüfungsordnung §1 - Prüfungsantritte (P0, cases 1-7).
* Prüfungsordnung §1 - Prüfungsantritte.
*
* Rules fire in order A (max Antritte) -> B (chronology) -> C (occurrence limit), so each test
* builds a state where exactly one can fire; otherwise an earlier rule masks the one under test.
*
* Bookkeeping: the first Termin2 add also auto-creates a Termin1 row with the original LV note, so
* one call produces two attempts. A later Termin2 add updates the existing non-excused Termin2
* instead of inserting. "entschuldigt" and "Noch nicht eingetragen" never count towards the cap.
* Bookkeeping since the Prüfungsverlauf refactor: one add writes one row. Antritt 1 is a real
* Prüfung (created by the Freigabe, seeded by givenBaseline). Attempts are counted by NOTE, not by
* type: "entschuldigt", "Noch nicht eingetragen" and "Nicht beurteilt" never count towards the cap.
*/
import { notenApi } from "../../../../support/api/notenApi";
@@ -21,11 +21,12 @@ import {
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
attemptsOfTyp,
attemptsOfStudent,
countingAttemptsOfStudent,
editPruefung,
enabledRetakeTypes,
givenBaseline,
readState,
verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
@@ -38,21 +39,33 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
cy.log(
`LV ${ctx.lvId} / ${ctx.semKurzbz} | maxAntritte=${ctx.maxAntritte} | ` +
`retakes=${enabledRetakeTypes(ctx).join(",") || "none"} | ` +
`entschuldigt=${ctx.notes.entschuldigt}`,
);
expect(
enabledRetakeTypes(ctx).length,
"needs TERMIN2 or TERMIN3 enabled, else saveStudentPruefung only answers wrongPruefungType",
).to.be.greaterThan(0);
ctx.maxAntritte,
"needs room for at least one retake beyond the first Antritt",
).to.be.greaterThan(1);
});
});
// one student per test, so a leaked row cannot reach the next scenario
const studentFor = (index) => ctx.students[index % ctx.students.length];
// Every rule below is keyed on these PKs. resolveSpecialNotes() resolves them from tbl_note by
/** Adds counting attempts until the cap is reached. Baseline already provides Antritt 1. */
const fillToCap = (student, firstIndex = 1) => {
for (let i = 0; i < ctx.maxAntritte - 1; i += 1) {
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, firstIndex + i),
}).then((response) => {
expectNotenSuccess(response, `attempt ${i + 2} of ${ctx.maxAntritte}`);
});
}
return attemptDate(ctx, firstIndex + ctx.maxAntritte - 1);
};
// Every rule below is keyed on these PKs. getSpecialNotes() resolves them from tbl_note by
// Bezeichnung and only falls back to the config values - on demo data entschuldigt is 14, not the
// 17 in application/config/noten.php. If the fallback ever wins, the rules silently stop matching.
it("resolves the special notes from tbl_note, not from config", () => {
@@ -71,41 +84,36 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
});
it("exposes the attempt cap instead of the retake type toggles", () => {
notenApi.getCisConfig().then((response) => {
const config = expectNotenSuccess(response, "getCisConfig");
expect(config.CIS_GESAMTNOTE_MAX_ANTRITTE, "the cap is derived server-side").to.eq(ctx.maxAntritte);
expect(config, "the client no longer needs the type toggles").to.not.have.property(
"CIS_GESAMTNOTE_PRUEFUNG_TERMIN2",
);
});
});
describe("Rule A - maximum number of Prüfungsantritte", () => {
it("rejects an attempt once the configured maximum is reached", () => {
const student = studentFor(0);
const retakes = enabledRetakeTypes(ctx);
givenBaseline(ctx, student);
// Antritt 1 is the seeded LV note; each retake type adds one row -> count reaches maxAntritte
retakes.forEach((typ, i) => {
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, i + 1),
typ,
}).then((response) => {
expectNotenSuccess(response, `add ${typ} #${i + 1} (still below the cap)`);
});
});
const nextDate = fillToCap(student);
// one more attempt of the last enabled type -> the cap must now bite
const lastTyp = retakes[retakes.length - 1];
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, retakes.length + 1),
typ: lastTyp,
}).then((response) => {
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nextDate }).then((response) => {
expectNotenError(response, "maxAntritteReached");
});
// and nothing was written for the rejected attempt
readState(ctx).then((data) => {
const attempts = attemptsOfTyp(data, student.uid, lastTyp);
expect(
attempts.map((p) => String(p.datum).slice(0, 10)),
`no ${lastTyp} may carry the rejected date`,
).to.not.include(attemptDate(ctx, retakes.length + 1));
const dates = attemptsOfStudent(data, student.uid).map((p) => String(p.datum).slice(0, 10));
expect(dates, "no row may carry the rejected date").to.not.include(nextDate);
expect(countingAttemptsOfStudent(data, student.uid), "exactly maxAntritte counting rows")
.to.have.length(ctx.maxAntritte);
});
});
});
@@ -115,12 +123,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
// cannot fire first and mask Rule B.
const givenOpenAttemptOn = (student, datum) => {
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notes.nochNichtEingetragen,
datum,
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, "seed an open (uncounted) Termin2");
addPruefung(ctx, student, { note: ctx.notes.nochNichtEingetragen, datum }).then((response) => {
expectNotenSuccess(response, "seed an open (uncounted) attempt");
});
};
@@ -130,28 +134,21 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenOpenAttemptOn(student, existing);
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: existing,
typ: "Termin2",
}).then((response) => {
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: existing }).then((response) => {
expectNotenError(response, "pruefungDatumBeforeExisting");
});
});
it("rejects a new attempt dated before an existing one", () => {
const student = studentFor(1);
const existing = attemptDate(ctx, 2);
givenOpenAttemptOn(student, existing);
givenOpenAttemptOn(student, attemptDate(ctx, 2));
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 1),
typ: "Termin2",
}).then((response) => {
expectNotenError(response, "pruefungDatumBeforeExisting");
});
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
(response) => {
expectNotenError(response, "pruefungDatumBeforeExisting");
},
);
});
it("accepts a new attempt dated after every existing one", () => {
@@ -159,13 +156,11 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenOpenAttemptOn(student, attemptDate(ctx, 2));
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 3),
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, "attempt strictly after the existing one");
});
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 3) }).then(
(response) => {
expectNotenSuccess(response, "attempt strictly after the existing one");
},
);
});
});
@@ -175,27 +170,19 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 1),
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, "first entschuldigt attempt");
});
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
(response) => expectNotenSuccess(response, "first entschuldigt attempt"),
);
addPruefung(ctx, student, {
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 2),
typ: "Termin2",
}).then((response) => {
expectNotenError(response, "noteOccuranceLimitReached");
});
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 2) }).then(
(response) => expectNotenError(response, "noteOccuranceLimitReached"),
);
readState(ctx).then((data) => {
const excused = attemptsOfTyp(data, student.uid, "Termin2").filter(
const excused = attemptsOfStudent(data, student.uid).filter(
(p) => String(p.note) === String(ctx.notes.entschuldigt),
);
expect(excused, "exactly one excused Termin2 may exist").to.have.length(1);
expect(excused, "exactly one excused attempt may exist").to.have.length(1);
});
});
});
@@ -206,24 +193,24 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 1),
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(response, "excused attempt");
});
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) }).then(
(response) => expectNotenSuccess(response, "excused attempt"),
);
// excused must not count, so this real grade is still within the cap
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 2),
typ: "Termin2",
}).then((response) => {
expectNotenSuccess(
response,
"a real grade after an excused attempt (excused must not count towards the cap)",
);
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
(response) =>
expectNotenSuccess(
response,
"a real grade after an excused attempt (excused must not count towards the cap)",
),
);
readState(ctx).then((data) => {
expect(
verlaufOfStudent(data, student.uid).antrittCount,
"baseline + the real grade, the excused one not counted",
).to.eq(2);
});
});
@@ -232,31 +219,23 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 1),
typ: "Termin2",
});
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 2),
typ: "Termin2",
});
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
readState(ctx).then((data) => {
const termin2 = attemptsOfTyp(data, student.uid, "Termin2");
const attempts = attemptsOfStudent(data, student.uid);
expect(termin2, "the excused row and the new row must coexist").to.have.length(2);
expect(attempts, "baseline, the excused row and the new row must coexist").to.have.length(3);
expect(String(termin2[0].note), "older row keeps the excused grade").to.eq(
expect(String(attempts[1].note), "second row keeps the excused grade").to.eq(
String(ctx.notes.entschuldigt),
);
expect(String(termin2[0].datum).slice(0, 10)).to.eq(attemptDate(ctx, 1));
expect(String(attempts[1].datum).slice(0, 10)).to.eq(attemptDate(ctx, 1));
expect(String(termin2[1].note), "newer row carries the real grade").to.eq(
expect(String(attempts[2].note), "third row carries the real grade").to.eq(
String(ctx.gradeNotes[0]),
);
expect(String(termin2[1].datum).slice(0, 10)).to.eq(attemptDate(ctx, 2));
expect(String(attempts[2].datum).slice(0, 10)).to.eq(attemptDate(ctx, 2));
});
});
});
@@ -275,12 +254,14 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
});
// dated after the kommPruef, so only Rule A can fire
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 2),
typ: enabledRetakeTypes(ctx)[0],
}).then((response) => {
expectNotenError(response, "maxAntritteReached");
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) }).then(
(response) => expectNotenError(response, "maxAntritteReached"),
);
readState(ctx).then((data) => {
const verlauf = verlaufOfStudent(data, student.uid);
expect(verlauf.terminal, "the Verlauf is marked terminal").to.be.true;
expect(verlauf.canAdd, "and closed for further attempts").to.be.false;
});
});
});
@@ -288,64 +269,68 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
describe("'Noch nicht eingetragen' does not consume an attempt", () => {
it("leaves the full cap available after an uncounted attempt", () => {
const student = studentFor(1);
const retakes = enabledRetakeTypes(ctx);
givenBaseline(ctx, student);
// occupies a date without counting; the first retake add also snapshots Termin1
// occupies a date without counting
addPruefung(ctx, student, {
note: ctx.notes.nochNichtEingetragen,
datum: attemptDate(ctx, 1),
typ: retakes[0],
}).then((response) => {
expectNotenSuccess(response, "uncounted attempt");
});
}).then((response) => expectNotenSuccess(response, "uncounted attempt"));
retakes.forEach((typ, i) => {
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, i + 2),
typ,
}).then((response) => {
expectNotenSuccess(response, `${typ} after an uncounted attempt`);
});
});
// the whole remaining cap is still available afterwards
const nextDate = fillToCap(student, 2);
// only now is the cap reached - the uncounted attempt consumed nothing
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, retakes.length + 2),
typ: retakes[retakes.length - 1],
}).then((response) => {
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: nextDate }).then((response) => {
expectNotenError(response, "maxAntritteReached");
});
});
});
describe("Anrechnung - no Prüfungen at all", () => {
it("refuses an attempt while the Zeugnisnote is angerechnet", () => {
const angerechnet = ctx.notes.angerechnet ?? ctx.notes.internAngerechnet;
if (!angerechnet) {
cy.log("no Anrechnungsnote in tbl_note - skipped");
return;
}
// the students of this LV carry their Zeugnisnote from the fixture; pick one that has an
// Anrechnung, otherwise there is nothing to assert here
readState(ctx).then((data) => {
const target = (data[0] || []).find((s) => String(s.note) === String(angerechnet));
if (!target) {
cy.log(`no student with Zeugnisnote ${angerechnet} in LV ${ctx.lvId} - skipped`);
return;
}
const verlauf = verlaufOfStudent(data, target.uid);
expect(verlauf.angerechnet, "the Verlauf marks the Anrechnung").to.be.true;
expect(verlauf.canAdd, "and blocks further attempts").to.be.false;
addPruefung(ctx, target, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }).then(
(response) => expectNotenError(response, "c4angerechnetKeinePruefung"),
);
});
});
});
describe("edit guards", () => {
/**
* Builds: Termin1 (baseline date) < excused Termin2 (attempt 1) < real Termin2 (attempt 2)
* Builds: Antritt 1 (baseline date) < excused (attempt 1) < real grade (attempt 2)
* and yields the excused row, which now has both an earlier and a later neighbour.
*/
const givenThreeAttempts = (student) => {
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 1),
typ: "Termin2",
});
addPruefung(ctx, student, {
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 2),
typ: "Termin2",
});
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) });
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 2) });
return readState(ctx).then((data) => {
const excused = attemptsOfTyp(data, student.uid, "Termin2").find(
const excused = attemptsOfStudent(data, student.uid).find(
(p) => String(p.note) === String(ctx.notes.entschuldigt),
);
expect(excused, "excused Termin2 to edit").to.exist;
expect(excused, "excused attempt to edit").to.exist;
return excused;
});
};
@@ -358,10 +343,7 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
pruefungId: excused.pruefung_id,
note: ctx.gradeNotes[1], // different grade -> locked
datum: attemptDate(ctx, 1),
typ: "Termin2",
}).then((response) => {
expectNotenError(response, "pruefungNoteLocked");
});
}).then((response) => expectNotenError(response, "pruefungNoteLocked"));
});
});
@@ -374,42 +356,44 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
pruefungId: excused.pruefung_id,
note: excused.note,
datum: shiftDate(attemptDate(ctx, 1), 3),
typ: "Termin2",
}).then((response) => {
// NOTE: savePruefungstermin() does not receive pruefung_id - it re-derives the target
// as "the first non-excused Termin2" (Noten.php:943-968). The validation under test
// is what this asserts; the resulting row placement is covered separately.
expectNotenSuccess(response, "date-only correction inside the neighbour bounds");
});
})
.then((response) => expectNotenSuccess(response, "date-only correction inside the bounds"))
.then(() => readState(ctx))
.then((data) => {
const moved = attemptsOfStudent(data, student.uid).find(
(p) => p.pruefung_id === excused.pruefung_id,
);
expect(moved, "the addressed row still exists").to.exist;
expect(String(moved.datum).slice(0, 10), "and it is the one that moved").to.eq(
shiftDate(attemptDate(ctx, 1), 3),
);
});
});
});
// The add-time limit is covered by Rule C; this is the edit-time re-check
// (validatePruefungEdit:1167). Its converse - re-saving the excused row itself, which the
// exclusion must let through - is the date-only correction above.
// The add-time limit is covered by Rule C; this is the edit-time re-check. Its converse -
// re-saving the excused row itself, which the exclusion must let through - is the date-only
// correction above.
it("rejects an edit that would exceed the entschuldigt limit", () => {
const student = studentFor(0);
// the real Termin2 is the latest attempt, so no note lock masks the limit
// the real attempt is the latest one, so no note lock masks the limit
givenThreeAttempts(student)
.then(() => readState(ctx))
.then((data) =>
attemptsOfTyp(data, student.uid, "Termin2").find(
(p) => String(p.note) !== String(ctx.notes.entschuldigt),
attemptsOfStudent(data, student.uid).find(
(p) => String(p.datum).slice(0, 10) === attemptDate(ctx, 2),
),
)
.then((real) => {
expect(real, "the non-excused Termin2").to.exist;
expect(real, "the latest attempt").to.exist;
return editPruefung(ctx, student, {
pruefungId: real.pruefung_id,
note: ctx.notes.entschuldigt,
datum: attemptDate(ctx, 2),
typ: "Termin2",
});
})
.then((response) => {
expectNotenError(response, "noteOccuranceLimitReached");
});
.then((response) => expectNotenError(response, "noteOccuranceLimitReached"));
});
it("rejects a date on or before the previous attempt", () => {
@@ -419,11 +403,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
editPruefung(ctx, student, {
pruefungId: excused.pruefung_id,
note: excused.note,
datum: baselineDate(ctx), // == the Termin1 date -> not strictly after
typ: "Termin2",
}).then((response) => {
expectNotenError(response, "pruefungDatumOutOfRange");
});
datum: baselineDate(ctx), // == the Antritt-1 date -> not strictly after
}).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
});
});
@@ -434,11 +415,8 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => {
editPruefung(ctx, student, {
pruefungId: excused.pruefung_id,
note: excused.note,
datum: attemptDate(ctx, 2), // == the later Termin2 -> not strictly before
typ: "Termin2",
}).then((response) => {
expectNotenError(response, "pruefungDatumOutOfRange");
});
datum: attemptDate(ctx, 2), // == the later attempt -> not strictly before
}).then((response) => expectNotenError(response, "pruefungDatumOutOfRange"));
});
});
});
@@ -1,16 +1,15 @@
/**
* savePruefungstermin - the write path shared by saveStudentPruefung, createPruefungen and
* savePruefungenBulk. The existing specs cover the validators; this one covers the writer.
* savePruefungFuerStudent / savePruefungstermin - the write path shared by saveStudentPruefung,
* createPruefungen and savePruefungenBulk. The other specs cover the validators; this one covers
* the writer.
*
* Core invariant since the Prüfungsverlauf refactor: one action writes exactly one Prüfung. The
* former Termin1 snapshot is gone - Antritt 1 is created by the password-gated Freigabe.
*/
import {
expectBulkRowError,
expectNotenError,
expectNotenSuccess,
} from "../../../../support/helpers/notenErrors";
import { expectNotenSuccess } from "../../../../support/helpers/notenErrors";
import {
attemptDate,
baselineDate,
loadNotenContext,
readLvGesamtnote,
requireDbReset,
@@ -20,75 +19,118 @@ import {
} from "../../../../support/helpers/notenTestData";
import {
addPruefung,
attemptsOfTyp,
attemptsOfStudent,
editPruefung,
givenBaseline,
readState,
verlaufOfStudent,
} from "../../../../support/helpers/notenScenario";
import { notenApi, pruefungenOfTyp } from "../../../../support/api/notenApi";
import { notenApi, pruefungenOf } from "../../../../support/api/notenApi";
const dayOf = (value) => String(value).slice(0, 10);
describe("Noten API - savePruefungstermin (write path)", () => {
describe("Noten API - Prüfungstermin (write path)", () => {
let ctx;
before(() => {
requireDbReset();
loadNotenContext().then((context) => {
ctx = context;
expect(
context.cisConfig.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2,
"needs TERMIN2 enabled - every test here writes a Termin2",
).to.be.ok;
expect(context.maxAntritte, "needs room for at least one retake").to.be.greaterThan(1);
});
});
const studentFor = (index) => ctx.students[index % ctx.students.length];
it("auto-creates Termin1 carrying the original LV note and its benotungsdatum", () => {
it("writes exactly one Prüfung per add - no snapshot alongside it", () => {
const student = studentFor(0);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
const [saved, , extra] = expectNotenSuccess(response, "first Termin2");
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
.then((response) => {
const [saved, lvgesamtnote, verlauf] = expectNotenSuccess(response, "add an attempt");
expect(extra, "Termin1 snapshot in extraPruefung").to.exist;
expect(extra.pruefungstyp_kurzbz).to.eq("Termin1");
expect(String(extra.note), "snapshot keeps the pre-retake LV note").to.eq(String(ctx.gradeNotes[0]));
expect(dayOf(extra.datum), "snapshot keeps the original benotungsdatum").to.eq(baselineDate(ctx));
expect(saved.pruefungstyp_kurzbz).to.eq("Termin2");
expect(String(saved.note)).to.eq(String(ctx.gradeNotes[1]));
});
expect(saved, "savedPruefung").to.exist;
expect(String(saved.note)).to.eq(String(ctx.gradeNotes[1]));
expect(lvgesamtnote, "lvgesamtnote is returned so the client can refresh the row").to.exist;
expect(verlauf, "verlauf is returned").to.exist;
expect(verlauf.pruefungen, "verlauf carries the attempts").to.be.an("array");
})
.then(() => readState(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "baseline Antritt 1 plus exactly one added row").to.have.length(2);
});
});
it("returns extraPruefung null once Termin1 exists", () => {
// On PHP 8 the null lands in count() (Noten.php:822) and turns this into a 500.
it("derives position and Antrittsnummer server-side", () => {
const student = studentFor(1);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "first Termin2")[0])
.then((created) =>
editPruefung(ctx, student, {
pruefungId: created.pruefung_id,
note: ctx.gradeNotes[0],
datum: attemptDate(ctx, 1),
}),
)
.then((response) => {
const [saved, lvgesamtnote, extra] = expectNotenSuccess(response, "edit of the Termin2");
.then(() => readState(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(saved, "savedPruefung").to.exist;
expect(extra, "no second Termin1 is created").to.be.null;
expect(lvgesamtnote, "lvgesamtnote").to.exist;
expect(attempts.map((p) => p.position), "positions are 1..n in date order").to.deep.eq([1, 2]);
expect(attempts.map((p) => p.antritt_nr), "both count as attempts").to.deep.eq([1, 2]);
attempts.forEach((p) => expect(p.zaehlt, `zaehlt of position ${p.position}`).to.be.true);
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);
expect(verlauf.canAdd, `canAdd with 2 of ${ctx.maxAntritte} used`).to.eq(2 < ctx.maxAntritte);
});
});
it("does not count an excused attempt, and keeps it as its own row", () => {
const student = studentFor(2);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "excused attempt"))
.then(() => readState(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "the excused row is kept, not merged").to.have.length(2);
const excused = attempts[1];
expect(excused.zaehlt, "excused consumes no attempt").to.be.false;
expect(excused.antritt_nr, "and carries no Antrittsnummer").to.be.null;
expect(verlaufOfStudent(data, student.uid).antrittCount, "still one attempt used").to.eq(1);
});
});
it("appends a new row per add instead of overwriting the previous one", () => {
// The old writer re-derived its target as "the first non-excused Termin2" and updated it.
// Now only an explicit pruefung_id updates; an add always inserts.
const student = studentFor(0);
givenBaseline(ctx, student);
let firstId;
addPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: attemptDate(ctx, 1) })
.then((response) => {
firstId = expectNotenSuccess(response, "first add")[0].pruefung_id;
return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) });
})
.then((response) => expectNotenSuccess(response, "second add"))
.then(() => readState(ctx))
.then((data) => {
const attempts = attemptsOfStudent(data, student.uid);
expect(attempts, "baseline + two added rows").to.have.length(3);
expect(attempts.map((p) => p.pruefung_id), "the first add still exists").to.include(firstId);
});
});
it("edits the pruefung identified by pruefung_id", () => {
// An excused and a real Termin2 side by side - no endpoint builds this, hence the direct seed
// An excused and a real attempt side by side - no endpoint builds this, hence the direct seed
const student = studentFor(2);
const excusedDate = attemptDate(ctx, 1);
const gradedDate = attemptDate(ctx, 2);
@@ -99,7 +141,6 @@ describe("Noten API - savePruefungstermin (write path)", () => {
let excusedId;
seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, typ: "Termin2" })
.then((seeded) => {
console.log('ctx', ctx)
excusedId = seeded.pruefungId;
return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, typ: "Termin2" });
})
@@ -111,133 +152,111 @@ describe("Noten API - savePruefungstermin (write path)", () => {
datum: movedTo,
}),
)
.then((response) => expectNotenSuccess(response, "moving the excused Termin2"))
.then((response) => expectNotenSuccess(response, "moving the excused attempt"))
.then(() => readState(ctx))
.then((data) => {
const rows = pruefungenOfTyp(data, student.uid, "Termin2");
const rows = pruefungenOf(data, student.uid);
const excused = rows.find((p) => p.pruefung_id === excusedId);
const graded = rows.find((p) => p.pruefung_id !== excusedId);
const graded = rows.find((p) => dayOf(p.datum) === gradedDate);
// the untargeted row first: silently rewriting a real grade is the worse outcome
expect(graded, "the other Termin2 still exists").to.exist;
expect(String(graded.note), "the untargeted Termin2 keeps its grade").to.eq(
expect(graded, "the other attempt still exists").to.exist;
expect(String(graded.note), "the untargeted attempt keeps its grade").to.eq(
String(ctx.gradeNotes[0]),
);
expect(dayOf(graded.datum), "the untargeted Termin2 keeps its date").to.eq(gradedDate);
expect(excused, "the excused Termin2 still exists").to.exist;
expect(excused, "the excused attempt still exists").to.exist;
expect(dayOf(excused.datum), "the row named by pruefung_id moved").to.eq(movedTo);
});
});
it("adds an attempt while the LV note is still offen", () => {
// freigegeben:false -> getLvGesamtNoten hides the row, so the writer takes the INSERT branch
// against an existing primary key.
// getLvGesamtNoten filters `freigabedatum < NOW()`. The writer must read the LV note through
// the UNFILTERED getter, otherwise it takes the INSERT branch against an existing primary key
// and answers "keine LV-Note eingetragen" forever.
const student = studentFor(3);
givenBaseline(ctx, student, { freigegeben: false });
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then((response) => {
expectNotenSuccess(response, "Termin2 on an un-freigegebene LV note");
expectNotenSuccess(response, "attempt on an un-freigegebene LV note");
});
});
it("updates the existing Termin2 instead of appending a second one", () => {
// The cap has room (Termin3 is on here), so the second add is allowed and must land on the
// same row - the update branch is otherwise only reached through an edit.
const student = studentFor(0);
givenBaseline(ctx, student);
let firstId;
addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) })
.then((response) => {
firstId = expectNotenSuccess(response, "first Termin2")[0].pruefung_id;
return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) });
})
.then((response) => expectNotenSuccess(response, "second Termin2"))
.then(() => readState(ctx))
.then((data) => {
const rows = attemptsOfTyp(data, student.uid, "Termin2");
expect(rows, "the second add must not append a row").to.have.length(1);
expect(rows[0].pruefung_id, "same row").to.eq(firstId);
expect(String(rows[0].note)).to.eq(String(ctx.gradeNotes[1]));
expect(dayOf(rows[0].datum)).to.eq(attemptDate(ctx, 2));
});
});
it("stores an empty note as 'Noch nicht eingetragen'", () => {
const student = studentFor(1);
givenBaseline(ctx, student);
addPruefung(ctx, student, { note: "", datum: attemptDate(ctx, 1) }).then((response) => {
const [saved] = expectNotenSuccess(response, "Termin2 without a grade");
const [saved] = expectNotenSuccess(response, "attempt without a grade");
expect(String(saved.note), "empty note is normalised").to.eq(
String(ctx.notes.nochNichtEingetragen),
);
});
});
describe("invalid pruefungstyp", () => {
// The only server-side typ check: which typ an attempt becomes is otherwise decided purely by
// the frontend (getPruefungstypForStudentByAntritt).
it("refuses a typ that is not an enabled retake", () => {
const student = studentFor(2);
it("writes the LV note from the attempt's grade", () => {
const student = studentFor(3);
givenBaseline(ctx, student);
givenBaseline(ctx, student);
// Termin1 is the snapshot, kommPruef is entered elsewhere, the third is nonsense
["Termin1", "kommPruef", "NichtsDergleichen"].forEach((typ) => {
addPruefung(ctx, student, {
note: ctx.gradeNotes[1],
datum: attemptDate(ctx, 1),
typ,
}).then((response) => {
expectNotenError(response, "wrongPruefungType");
});
addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) })
.then((response) => expectNotenSuccess(response, "attempt with a grade"))
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "the LV note follows the newest attempt").to.eq(
String(ctx.gradeNotes[1]),
);
});
});
it("leaves the LV note untouched when the typ is rejected", () => {
const student = studentFor(3);
givenBaseline(ctx, student);
addPruefung(ctx, student, {
note: ctx.gradeNotes[1],
datum: attemptDate(ctx, 1),
typ: "NichtsDergleichen",
})
.then((response) => expectNotenError(response, "wrongPruefungType"))
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(String(row.note), "a rejected request must not re-grade the student").to.eq(
String(ctx.gradeNotes[0]),
);
});
});
});
it("refuses a Prüfung for a student who has no LV note yet", () => {
// Only reachable through the bulk path: saveStudentPruefung inserts the missing LV note itself,
// so its own guard (Noten.php:839) can never fire.
it("creates the LV note when a student has none yet", () => {
// Previously this answered c4keineLvNoteEingetragen. The bulk path now runs the same core as
// the single dialog, which writes the LV note before the Prüfung.
const student = studentFor(0);
resetNotenState(ctx);
notenApi
.createPruefungen(
[{ uid: student.uid, typ: "Termin2", lehreinheit_id: student.lehreinheit_id }],
[{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
attemptDate(ctx, 1),
ctx.lvId,
ctx.semKurzbz,
)
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen without an LV note");
expectBulkRowError(data, student.uid, "c4keineLvNoteEingetragen");
expect(data[student.uid], `row result for ${student.uid}`).to.be.an("object");
expect(data[student.uid].savedPruefung, "the Prüfung was written").to.exist;
})
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => {
expect(row, "the LV note was created alongside").to.not.be.null;
expect(String(row.note), "carries the Prüfung's note").to.eq(
String(ctx.notes.nochNichtEingetragen),
);
});
});
it("applies a chosen note to both the Prüfung and the LV note in the bulk path", () => {
const student = studentFor(1);
resetNotenState(ctx);
notenApi
.createPruefungen(
[{ uid: student.uid, lehreinheit_id: student.lehreinheit_id }],
attemptDate(ctx, 1),
ctx.lvId,
ctx.semKurzbz,
ctx.gradeNotes[1],
)
.then((response) => {
const data = expectNotenSuccess(response, "createPruefungen with a note");
expect(String(data[student.uid].savedPruefung[0].note)).to.eq(String(ctx.gradeNotes[1]));
})
.then(() => readLvGesamtnote(ctx, student.uid))
.then((row) => expect(String(row.note)).to.eq(String(ctx.gradeNotes[1])));
});
});
+24 -5
View File
@@ -41,12 +41,16 @@ export const notenApi = {
saveNotenvorschlag: (lv_id, sem_kurzbz, student_uid, note, punkte = null) =>
apiPost("saveNotenvorschlag", { lv_id, sem_kurzbz, student_uid, note, punkte }),
/** data -> [savedPruefung, lvgesamtnote, extraPruefung(auto-created Termin1)] */
/**
* data -> [savedPruefung, lvgesamtnote, verlauf]
* No `typ` on the wire: which attempt a Prüfung becomes is derived server-side from the
* existing Prüfungsverlauf (PruefungsverlaufLib).
*/
saveStudentPruefung: ({
student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null,
student_uid, note, punkte = null, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null,
}) =>
apiPost("saveStudentPruefung", {
student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id,
student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id,
}),
/** LDAP-password gated. data -> [{uid, freigabedatum, benotungsdatum}] */
@@ -64,8 +68,9 @@ export const notenApi = {
savePruefungenBulk: (lv_id, sem_kurzbz, pruefungen) =>
apiPost("savePruefungenBulk", { lv_id, sem_kurzbz, pruefungen }),
createPruefungen: (uids, datum, lva_id, sem_kurzbz) =>
apiPost("createPruefungen", { uids, datum, lva_id, sem_kurzbz }),
// note/punkte are optional; without them the Prüfung is created as "Noch nicht eingetragen"
createPruefungen: (uids, datum, lva_id, sem_kurzbz, note = null, punkte = null) =>
apiPost("createPruefungen", { uids, datum, lva_id, sem_kurzbz, note, punkte }),
};
// --- selectors over the getStudentenNoten payload ---
@@ -73,7 +78,21 @@ export const notenApi = {
export const pruefungenOf = (data, uid) =>
(data[1] || []).filter((p) => p.student_uid === uid);
/**
* Attempts in Verlauf order. position/zaehlt/antritt_nr/terminal are derived by the server; the
* specs assert on those and never on pruefungstyp_kurzbz, which is only a legacy projection.
*/
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. */
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 gradesOf = (data, uid) => (data[3] || {})[uid];
/** Server-derived rule state per student: antrittCount, maxAntritte, canAdd, terminal, angerechnet. */
export const verlaufOf = (data, uid) => (gradesOf(data, uid) || {}).verlauf;
+16 -15
View File
@@ -1,19 +1,19 @@
/** Building attempt histories and reading the resulting state. */
import { notenApi, pruefungenOfTyp, gradesOf } from "../api/notenApi";
import { notenApi, attemptsOf, countingAttemptsOf, gradesOf, verlaufOf } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
import { resetNotenState, seedBaseline } from "./notenTestData";
import { retakeTypes } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js";
/** The real client rule, so a divergence from the server shows up as a failing API spec. */
export const enabledRetakeTypes = (context) => retakeTypes(context.cisConfig);
/**
* Adds an attempt (no pruefung_id -> server runs validatePruefungAdd).
*
* No typ: the server derives from the Verlauf which attempt this is. One call writes exactly one
* Prüfung - the former Termin1 snapshot is gone, the first attempt is created by the Freigabe.
*
* punkte stays null: with CIS_GESAMTNOTE_PUNKTE on, a punkte >= 0 makes the controller re-derive
* the note from the Notenschlüssel and override the grade under test (Noten.php:692).
* the note from the Notenschlüssel and override the grade under test.
*/
export const addPruefung = (context, student, { note, datum, typ = "Termin2" }) =>
export const addPruefung = (context, student, { note, datum }) =>
notenApi.saveStudentPruefung({
student_uid: student.uid,
note,
@@ -22,12 +22,11 @@ export const addPruefung = (context, student, { note, datum, typ = "Termin2" })
lva_id: context.lvId,
lehreinheit_id: student.lehreinheit_id,
sem_kurzbz: context.semKurzbz,
typ,
pruefung_id: null,
});
/** Edits an attempt (pruefung_id set -> server runs validatePruefungEdit). */
export const editPruefung = (context, student, { pruefungId, note, datum, typ = "Termin2" }) =>
export const editPruefung = (context, student, { pruefungId, note, datum }) =>
notenApi.saveStudentPruefung({
student_uid: student.uid,
note,
@@ -36,7 +35,6 @@ export const editPruefung = (context, student, { pruefungId, note, datum, typ =
lva_id: context.lvId,
lehreinheit_id: student.lehreinheit_id,
sem_kurzbz: context.semKurzbz,
typ,
pruefung_id: pruefungId,
});
@@ -45,11 +43,14 @@ export const readState = (context) =>
.getStudentenNoten(context.lvId, context.semKurzbz)
.then((response) => expectNotenSuccess(response, "getStudentenNoten"));
/** Attempts of one student and type, oldest first (the API returns datum DESC). */
export const attemptsOfTyp = (data, uid, typ) =>
[...pruefungenOfTyp(data, uid, typ)].sort((a, b) =>
String(a.datum).slice(0, 10).localeCompare(String(b.datum).slice(0, 10)),
);
/** All attempts of one student, in Verlauf order (position ascending). */
export const attemptsOfStudent = (data, uid) => attemptsOf(data, uid);
/** Attempts that consume an Antritt, in Verlauf order. */
export const countingAttemptsOfStudent = (data, uid) => countingAttemptsOf(data, uid);
/** The server's rule state for one student. */
export const verlaufOfStudent = (data, uid) => verlaufOf(data, uid);
export const lvNoteOf = (data, uid) => gradesOf(data, uid);
+36 -8
View File
@@ -9,7 +9,7 @@
import { notenApi } from "../api/notenApi";
import { expectNotenSuccess } from "./notenErrors";
// the client rule; the server's computeMaxAntritte must agree with it
// 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";
import {
describeFailure, performRead, performReset, performSeed, performSeedPruefung, resolveResetStrategy,
@@ -154,10 +154,16 @@ export const loadNotenContext = () => {
// administrative note the editor list excludes ("intern angerechnet" / "nicht zugelassen")
const nichtLehre = noten.find((n) => n.lehre === false);
// Anrechnungen block every Prüfung for the LV - keyed on the ZEUGNISnote
const angerechnet = byBezeichnung("angerechnet");
const internAngerechnet = byBezeichnung("intern angerechnet");
context.notes = {
entschuldigt: entschuldigt.note,
nochNichtEingetragen: nochNicht.note,
nichtLehre: nichtLehre ? nichtLehre.note : null,
angerechnet: angerechnet ? angerechnet.note : null,
internAngerechnet: internAngerechnet ? internAngerechnet.note : null,
};
return notenApi.getStudentenNoten(context.lvId, context.semKurzbz);
@@ -183,18 +189,40 @@ export const resetNotenState = (context, studentUids) =>
performReset(context, studentUids || context.studentUids);
/**
* Seeds the Antritt-1 baseline. Defaults to freigegeben:true because validatePruefungAdd reads the
* implicit first Antritt through getLvGesamtNoten(), which filters `freigabedatum < NOW()` - an
* offene note is invisible there and would not count as Antritt 1.
* Seeds the Antritt-1 baseline: a released LV note PLUS the Prüfung row that represents it.
*
* The row is what the production flow produces - since the refactor the first Antritt is created
* by the password-gated Freigabe (Noten::upsertErstantritt), not as a side effect of adding a
* retake. Without it the LV note only counts while no Prüfung exists at all, so the first added
* attempt would silently replace Antritt 1 instead of being Antritt 2.
*
* `erstantritt: false` seeds the legacy shape (LV note only, no row) for tests that need it.
* `freigegeben: false` implies it too: no Freigabe, no first Antritt.
*/
export const seedBaseline = (context, studentUid, options = {}) =>
performSeed(context, studentUid, {
note: options.note !== undefined ? options.note : context.gradeNotes[0],
export const seedBaseline = (context, studentUid, options = {}) => {
const note = options.note !== undefined ? options.note : context.gradeNotes[0];
const freigegeben = options.freigegeben !== undefined ? options.freigegeben : true;
return performSeed(context, studentUid, {
note,
punkte: options.punkte !== undefined ? options.punkte : null,
benotungsdatum: options.benotungsdatum || baselineBenotungsdatum(context),
freigegeben: options.freigegeben !== undefined ? options.freigegeben : true,
freigegeben,
freigabedatum: options.freigabedatum || null,
}).then((seeded) => {
if (options.erstantritt === false || !freigegeben) return cy.wrap(seeded, { log: false });
const student = context.students.find((s) => s.uid === studentUid);
expect(student, `student ${studentUid} in the loaded LV`).to.exist;
return performSeedPruefung(context, studentUid, {
lehreinheitId: student.lehreinheit_id,
note,
datum: baselineDate(context),
typ: "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 }) =>