waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Sicherung des Lernstands. |
| 3 | * |
| 4 | * Alles hier läuft gegen echte SQLite-Dateien auf der Platte, nicht gegen |
| 5 | * Attrappen: Die Fallen dieses Merkmals sind Fallen von SQLite selbst, und |
| 6 | * eine Attrappe hätte keine davon. |
| 7 | */ |
| 8 | |
| 9 | import { |
| 10 | copyFileSync, |
| 11 | existsSync, |
| 12 | mkdtempSync, |
| 13 | readFileSync, |
| 14 | rmSync, |
| 15 | statSync, |
| 16 | writeFileSync, |
| 17 | } from 'node:fs'; |
| 18 | import { tmpdir } from 'node:os'; |
| 19 | import { join } from 'node:path'; |
| 20 | |
| 21 | import Database from 'better-sqlite3'; |
| 22 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 23 | |
| 24 | import { SCHEMA_VERSION } from '../src/main/schema'; |
| 25 | import { |
| 26 | ANWENDUNGSKENNUNG, |
| 27 | aufraeumen, |
| 28 | dateiPruefen, |
| 29 | kennzahlenLesen, |
| 30 | nebendateienAufraeumen, |
| 31 | sicherungsDateiname, |
| 32 | sicherungSchreiben, |
| 33 | } from '../src/main/sicherung'; |
| 34 | |
| 35 | let ordner: string; |
| 36 | let offene: Database.Database[]; |
| 37 | |
| 38 | /** Ein Lernstand, wie ihn die Anwendung anlegt – im WAL-Modus. */ |
| 39 | function lernstandAnlegen(pfad: string, antworten = 5): Database.Database { |
| 40 | const db = new Database(pfad); |
| 41 | offene.push(db); |
| 42 | db.pragma('journal_mode = WAL'); |
| 43 | db.exec(` |
| 44 | CREATE TABLE schema_version (version INTEGER PRIMARY KEY); |
| 45 | CREATE TABLE profil (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL); |
| 46 | CREATE TABLE frage_stand ( |
| 47 | profil_id INTEGER NOT NULL REFERENCES profil(id), |
| 48 | frage_id TEXT NOT NULL, |
| 49 | gemerkt INTEGER NOT NULL DEFAULT 0, |
| 50 | PRIMARY KEY (profil_id, frage_id) |
| 51 | ); |
| 52 | CREATE TABLE antwort_log ( |
| 53 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 54 | profil_id INTEGER NOT NULL REFERENCES profil(id), |
| 55 | zeitpunkt TEXT NOT NULL |
| 56 | ); |
| 57 | `); |
| 58 | for (let version = 1; version <= SCHEMA_VERSION; version++) { |
| 59 | db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version); |
| 60 | } |
| 61 | db.prepare('INSERT INTO profil (name) VALUES (?)').run('Standard'); |
| 62 | const einfuegen = db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt) VALUES (1, ?)'); |
| 63 | const alle = db.transaction(() => { |
| 64 | for (let i = 0; i < antworten; i++) { |
| 65 | einfuegen.run(`2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`); |
| 66 | } |
| 67 | }); |
| 68 | alle(); |
| 69 | db.prepare('INSERT INTO frage_stand (profil_id, frage_id, gemerkt) VALUES (1, ?, 1)').run( |
| 70 | 'I.1-01', |
| 71 | ); |
| 72 | return db; |
| 73 | } |
| 74 | |
| 75 | beforeEach(() => { |
| 76 | ordner = mkdtempSync(join(tmpdir(), 'wsk-sicherung-')); |
| 77 | offene = []; |
| 78 | }); |
| 79 | |
| 80 | afterEach(() => { |
| 81 | for (const db of offene) { |
| 82 | try { |
| 83 | db.close(); |
| 84 | } catch { |
| 85 | /* Schon zu. */ |
| 86 | } |
| 87 | } |
| 88 | rmSync(ordner, { recursive: true, force: true }); |
| 89 | }); |
| 90 | |
| 91 | describe('Warum eine bloße Dateikopie nicht genügt', () => { |
| 92 | it('verliert bei offener Verbindung im WAL-Modus alles – und meldet trotzdem „ok“', () => { |
| 93 | /* |
| 94 | Der Messwert, der die ganze Umsetzung begründet. Er steht hier als Test, |
| 95 | damit niemand später auf die naheliegende Abkürzung verfällt: Die Kopie |
| 96 | ist nicht bloß älter, sie ist LEER – bei tadellosem integrity_check. |
| 97 | */ |
| 98 | const quelle = join(ordner, 'lernstand.db'); |
| 99 | lernstandAnlegen(quelle, 200); |
| 100 | |
| 101 | const kopie = join(ordner, 'nur-hauptdatei.db'); |
| 102 | copyFileSync(quelle, kopie); |
| 103 | |
| 104 | const db = new Database(kopie, { readonly: true }); |
| 105 | offene.push(db); |
| 106 | const zeilen = db.pragma('integrity_check') as { integrity_check: string }[]; |
| 107 | expect(zeilen[0]?.integrity_check).toBe('ok'); |
| 108 | |
| 109 | const tabellen = db |
| 110 | .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") |
| 111 | .all(); |
| 112 | expect(tabellen).toHaveLength(0); |
| 113 | }); |
| 114 | }); |
| 115 | |
| 116 | describe('Eine Sicherung schreiben', () => { |
| 117 | it('nimmt alles mit, obwohl die Verbindung offen bleibt', () => { |
| 118 | const quelle = join(ordner, 'lernstand.db'); |
| 119 | const db = lernstandAnlegen(quelle, 200); |
| 120 | |
| 121 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 122 | const bytes = sicherungSchreiben(db, ziel, Database); |
| 123 | |
| 124 | expect(bytes).toBeGreaterThan(0); |
| 125 | const gelesen = new Database(ziel, { readonly: true }); |
| 126 | offene.push(gelesen); |
| 127 | expect( |
| 128 | gelesen.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log').get() |
| 129 | ?.anzahl, |
| 130 | ).toBe(200); |
| 131 | }); |
| 132 | |
| 133 | it('hinterlässt genau eine Datei, auch nachdem sie gelesen wurde', () => { |
| 134 | /* |
| 135 | Der Grund gegen `db.backup()`: Dessen Ergebnis steht im WAL-Modus, und |
| 136 | wer es auch nur ansieht, erzeugt daneben `-wal` und `-shm`, die nach dem |
| 137 | Schließen liegen bleiben. Eine Sicherung, von der man zwei Dateien |
| 138 | vergisst, ist genau der Fehler, den sie verhüten soll. |
| 139 | */ |
| 140 | const db = lernstandAnlegen(join(ordner, 'lernstand.db')); |
| 141 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 142 | sicherungSchreiben(db, ziel, Database); |
| 143 | |
| 144 | const gelesen = new Database(ziel, { readonly: true }); |
| 145 | expect((gelesen.pragma('journal_mode') as { journal_mode: string }[])[0]?.journal_mode).toBe( |
| 146 | 'delete', |
| 147 | ); |
| 148 | gelesen.close(); |
| 149 | |
| 150 | expect(existsSync(`${ziel}-wal`)).toBe(false); |
| 151 | expect(existsSync(`${ziel}-shm`)).toBe(false); |
| 152 | }); |
| 153 | |
| 154 | it('trägt die Kennung dieser Anwendung und die Schemafassung im Kopf', () => { |
| 155 | const db = lernstandAnlegen(join(ordner, 'lernstand.db')); |
| 156 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 157 | sicherungSchreiben(db, ziel, Database); |
| 158 | |
| 159 | const gelesen = new Database(ziel, { readonly: true }); |
| 160 | offene.push(gelesen); |
| 161 | expect( |
| 162 | (gelesen.pragma('application_id') as { application_id: number }[])[0]?.application_id, |
| 163 | ).toBe(ANWENDUNGSKENNUNG); |
| 164 | expect((gelesen.pragma('user_version') as { user_version: number }[])[0]?.user_version).toBe( |
| 165 | SCHEMA_VERSION, |
| 166 | ); |
| 167 | }); |
| 168 | |
| 169 | it('lässt keine Teildatei zurück', () => { |
| 170 | /* Erst nach `.teil`, dann umbenennen: Ein Abbruch mitten im Schreiben |
| 171 | hinterlässt damit nie eine halbe Datei unter dem richtigen Namen. */ |
| 172 | const db = lernstandAnlegen(join(ordner, 'lernstand.db')); |
| 173 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 174 | sicherungSchreiben(db, ziel, Database); |
| 175 | |
| 176 | expect(existsSync(`${ziel}.teil`)).toBe(false); |
| 177 | }); |
| 178 | |
| 179 | it('nimmt einen Pfad mit Anführungszeichen im Namen', () => { |
| 180 | /* Der Zielpfad wird gebunden und nicht in die Anweisung eingesetzt. |
| 181 | Eingesetzt zerlegte ein Anführungszeichen die Anweisung. */ |
| 182 | const db = lernstandAnlegen(join(ordner, 'lernstand.db')); |
| 183 | const ziel = join(ordner, "Olafs 'Sicherung'.wsklernstand"); |
| 184 | |
| 185 | expect(() => sicherungSchreiben(db, ziel, Database)).not.toThrow(); |
| 186 | expect(existsSync(ziel)).toBe(true); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | describe('Die Prüfkette', () => { |
| 191 | it('nimmt eine echte Sicherung an und beziffert sie', () => { |
| 192 | const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 42); |
| 193 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 194 | sicherungSchreiben(db, ziel, Database); |
| 195 | |
| 196 | const befund = dateiPruefen(ziel, Database); |
| 197 | |
| 198 | expect(befund.art).toBe('brauchbar'); |
| 199 | if (befund.art !== 'brauchbar') { |
| 200 | return; |
| 201 | } |
| 202 | expect(befund.kennzahlen.profilnamen).toEqual(['Standard']); |
| 203 | expect(befund.kennzahlen.antworten).toBe(42); |
| 204 | expect(befund.kennzahlen.gemerkt).toBe(1); |
| 205 | expect(befund.kennzahlen.schemafassung).toBe(SCHEMA_VERSION); |
| 206 | expect(befund.kennzahlen.letzteAntwort).not.toBeNull(); |
| 207 | }); |
| 208 | |
| 209 | it('weist eine leere Datei ab – der gefährlichste Fall überhaupt', () => { |
| 210 | /* |
| 211 | Eine Datei von null Byte besteht `integrity_check` mit „ok“ und hat null |
| 212 | Tabellen. Ohne diese Abweisung durchliefe sie die vollständige |
| 213 | Migrationskette und stünde als tadelloser, LEERER Lernstand da. Das |
| 214 | Einspielen meldete Erfolg, und die Arbeit von Wochen wäre fort. |
| 215 | */ |
| 216 | const leer = join(ordner, 'leer.wsklernstand'); |
| 217 | writeFileSync(leer, ''); |
| 218 | |
| 219 | const befund = dateiPruefen(leer, Database); |
| 220 | |
| 221 | expect(befund.art).toBe('abgelehnt'); |
| 222 | expect(befund.art === 'abgelehnt' && befund.grund).toContain('Es wurde nichts verändert.'); |
| 223 | }); |
| 224 | |
| 225 | it('weist eine gültige, aber fremde SQLite-Datenbank ab', () => { |
| 226 | /* Auch eine Datenbank mit null Tabellen ist syntaktisch tadellos. Was |
| 227 | fehlt, sind die Tabellen dieser Anwendung. */ |
| 228 | const fremd = join(ordner, 'fremd.db'); |
| 229 | const db = new Database(fremd); |
| 230 | db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY, name TEXT)'); |
| 231 | db.close(); |
| 232 | |
| 233 | const befund = dateiPruefen(fremd, Database); |
| 234 | |
| 235 | expect(befund.art).toBe('abgelehnt'); |
| 236 | expect(befund.art === 'abgelehnt' && befund.grund).toContain('kein Lernstand dieser Anwendung'); |
| 237 | }); |
| 238 | |
| 239 | it('weist ein umbenanntes Bild ab, ohne eine englische Datenbankmeldung', () => { |
| 240 | /* Eine Textdatei lässt sich readonly ÖFFNEN – erst die erste Abfrage |
| 241 | wirft SQLITE_NOTADB. Deshalb wird der Dateikopf vorher gelesen. */ |
| 242 | const foto = join(ordner, 'urlaub.wsklernstand'); |
| 243 | writeFileSync(foto, 'x'.repeat(2048)); |
| 244 | |
| 245 | const befund = dateiPruefen(foto, Database); |
| 246 | |
| 247 | expect(befund.art).toBe('abgelehnt'); |
| 248 | expect(befund.art === 'abgelehnt' && befund.grund).toContain('keine Datenbank'); |
| 249 | expect(befund.art === 'abgelehnt' && befund.grund).not.toMatch(/SQLITE_|not a database/u); |
| 250 | }); |
| 251 | |
| 252 | it('weist eine abgeschnittene Datei ab', () => { |
| 253 | const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 500); |
| 254 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 255 | sicherungSchreiben(db, ziel, Database); |
| 256 | |
| 257 | const halb = join(ordner, 'halb.wsklernstand'); |
| 258 | const { size } = statSync(ziel); |
| 259 | const inhalt = readFileSync(ziel); |
| 260 | writeFileSync(halb, inhalt.subarray(0, Math.floor(size * 0.4))); |
| 261 | |
| 262 | const befund = dateiPruefen(halb, Database); |
| 263 | |
| 264 | expect(befund.art).toBe('abgelehnt'); |
| 265 | }); |
| 266 | |
| 267 | it('weist eine Sicherung aus einer neueren Programmfassung ab', () => { |
| 268 | /* Vorwärts migrieren kann die Anwendung, rückwärts nicht. Eine neuere |
| 269 | Sicherung stillschweigend anzunehmen hiesse, Spalten zu verlieren. */ |
| 270 | const db = lernstandAnlegen(join(ordner, 'lernstand.db')); |
| 271 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 272 | sicherungSchreiben(db, ziel, Database); |
| 273 | |
| 274 | /* Die Fassung wird erst NACH dem Schreiben hochgesetzt: Diese Anwendung |
| 275 | kann eine solche Datei gar nicht erzeugen – sie entsteht auf einem |
| 276 | Rechner mit neuerem Programm. */ |
| 277 | const zukunft = join(ordner, 'zukunft.wsklernstand'); |
| 278 | copyFileSync(ziel, zukunft); |
| 279 | const neuer = new Database(zukunft); |
| 280 | neuer.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION + 1); |
| 281 | neuer.close(); |
| 282 | |
| 283 | const befund = dateiPruefen(zukunft, Database); |
| 284 | |
| 285 | expect(befund.art).toBe('abgelehnt'); |
| 286 | expect(befund.art === 'abgelehnt' && befund.grund).toContain('neueren Fassung'); |
| 287 | }); |
| 288 | |
| 289 | it('nimmt eine ältere Sicherung an – die Migrationskette zieht sie hoch', () => { |
| 290 | /* |
| 291 | `docs/stand.md` sagte „bei gleicher Schemafassung". Das ist zu streng: |
| 292 | Eine alte Sicherung einzuspielen ist byteweise dieselbe Handlung wie ein |
| 293 | alter Lernstand nach einem Programmupdate, und diesen Weg geht jeder |
| 294 | Nutzer nach jeder Aktualisierung. |
| 295 | */ |
| 296 | const db = lernstandAnlegen(join(ordner, 'lernstand.db')); |
| 297 | db.prepare('DELETE FROM schema_version WHERE version > 3').run(); |
| 298 | const ziel = join(ordner, 'alt.wsklernstand'); |
| 299 | sicherungSchreiben(db, ziel, Database); |
| 300 | |
| 301 | const befund = dateiPruefen(ziel, Database); |
| 302 | |
| 303 | expect(befund.art).toBe('brauchbar'); |
| 304 | expect(befund.art === 'brauchbar' && befund.kennzahlen.schemafassung).toBe(3); |
| 305 | }); |
| 306 | |
| 307 | it('lässt nach jeder Abweisung die Datei wieder los', () => { |
| 308 | /* Bleibt die Verbindung offen, ist die Datei unter Windows gesperrt und |
| 309 | liesse sich weder löschen noch umbenennen. */ |
| 310 | const fremd = join(ordner, 'fremd.db'); |
| 311 | const db = new Database(fremd); |
| 312 | db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY)'); |
| 313 | db.close(); |
| 314 | |
| 315 | expect(dateiPruefen(fremd, Database).art).toBe('abgelehnt'); |
| 316 | expect(() => { |
| 317 | rmSync(fremd); |
| 318 | }).not.toThrow(); |
| 319 | }); |
| 320 | }); |
| 321 | |
| 322 | describe('Der Dateiname', () => { |
| 323 | it('trägt Datum und Uhrzeit auf die Sekunde genau', () => { |
| 324 | /* Auf die Sekunde, damit zwei Sicherungen in derselben Minute einander |
| 325 | nicht überschreiben – die selbsttätige Sicherheitskopie vor dem |
| 326 | Einspielen fragt niemanden. */ |
| 327 | const name = sicherungsDateiname(new Date(2026, 7, 22, 14, 32, 7)); |
| 328 | |
| 329 | expect(name).toBe('Waffensachkunde-Lernstand-2026-08-22-143207.wsklernstand'); |
| 330 | }); |
| 331 | |
| 332 | it('nennt keinen Profilnamen', () => { |
| 333 | /* Gesichert wird die ganze Datenbank mit allen Profilen. Ein Name darin |
| 334 | wäre gelogen und verleitete dazu, die Datei später für die Sicherung |
| 335 | eines einzelnen Profils zu halten. */ |
| 336 | expect(sicherungsDateiname(new Date())).not.toMatch(/Standard|Olaf/u); |
| 337 | }); |
| 338 | }); |
| 339 | |
| 340 | describe('Aufräumen und Kennzahlen', () => { |
| 341 | /* |
| 342 | Nachgemessen: Wird eine Datenbankdatei nur LESEND geöffnet, bleiben |
| 343 | danach ein 0 Byte grosses `-wal` und ein 32 KiB grosses `-shm` liegen. |
| 344 | Im heutigen Ablauf ist das harmlos – der nächste Durchgang liest die |
| 345 | richtige Datei. Läge dort aber je ein GEFÜLLTES `-wal`, bekäme SQLite den |
| 346 | Inhalt der vorigen Datenbank untergeschoben, bei grünem |
| 347 | `integrity_check`; nachgestellt: erwartet 900 Zeilen, gelesen 5000 aus der |
| 348 | anderen Datei. Deshalb wird es entfernt, bevor der erste schreibende |
| 349 | Zugriff auf eine Arbeitskopie dazukommt. |
| 350 | */ |
| 351 | it('nimmt beim Aufräumen die Nebendateien mit', () => { |
| 352 | const pfad = join(ordner, 'stand.db'); |
| 353 | /* Die schreibende Verbindung muss zu sein, sonst hält Windows die |
| 354 | Nebendateien und der Test misst das Betriebssystem statt den Code. */ |
| 355 | lernstandAnlegen(pfad).close(); |
| 356 | |
| 357 | const lesend = new Database(pfad, { readonly: true }); |
| 358 | offene.push(lesend); |
| 359 | lesend.prepare('SELECT COUNT(*) AS n FROM antwort_log').get(); |
| 360 | lesend.close(); |
| 361 | |
| 362 | expect(existsSync(`${pfad}-shm`)).toBe(true); |
| 363 | |
| 364 | aufraeumen(pfad); |
| 365 | |
| 366 | expect(existsSync(pfad)).toBe(false); |
| 367 | expect(existsSync(`${pfad}-wal`)).toBe(false); |
| 368 | expect(existsSync(`${pfad}-shm`)).toBe(false); |
| 369 | }); |
| 370 | |
| 371 | it('stört sich nicht daran, wenn nichts da ist', () => { |
| 372 | expect(() => { |
| 373 | aufraeumen(join(ordner, 'gibt-es-nicht.db')); |
| 374 | }).not.toThrow(); |
| 375 | }); |
| 376 | |
| 377 | /* |
| 378 | Beim Anlegen einer Sicherung über eine vorhandene darf nur das |
| 379 | Schreibprotokoll fallen, nie die Datei selbst: Bis 0.24.1 wurde sie vorher |
| 380 | gelöscht, und ein Fehlschlag danach nahm beide Fassungen mit. |
| 381 | */ |
| 382 | it('räumt nur die Nebendateien weg und lässt die Sicherung stehen', () => { |
| 383 | const pfad = join(ordner, 'sicherung.wsklernstand'); |
| 384 | writeFileSync(pfad, 'ALTE SICHERUNG', 'utf8'); |
| 385 | writeFileSync(`${pfad}-wal`, 'protokoll', 'utf8'); |
| 386 | writeFileSync(`${pfad}-shm`, 'gemeinsam', 'utf8'); |
| 387 | |
| 388 | nebendateienAufraeumen(pfad); |
| 389 | |
| 390 | expect(readFileSync(pfad, 'utf8')).toBe('ALTE SICHERUNG'); |
| 391 | expect(existsSync(`${pfad}-wal`)).toBe(false); |
| 392 | expect(existsSync(`${pfad}-shm`)).toBe(false); |
| 393 | }); |
| 394 | |
| 395 | /* |
| 396 | Der Grund, warum das Vorab-Löschen entbehrlich ist: `sicherungSchreiben` |
| 397 | schreibt nach `<ziel>.teil` und schiebt die fertige Datei mit `renameSync` |
| 398 | an ihren Platz. Das ersetzt ein vorhandenes Ziel unteilbar. |
| 399 | */ |
| 400 | it('ersetzt eine vorhandene Zieldatei, ohne dass sie vorher gelöscht wird', () => { |
| 401 | const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 30); |
| 402 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 403 | writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8'); |
| 404 | |
| 405 | const bytes = sicherungSchreiben(db, ziel, Database); |
| 406 | |
| 407 | expect(bytes).toBeGreaterThan(0); |
| 408 | expect(dateiPruefen(ziel, Database).art).toBe('brauchbar'); |
| 409 | }); |
| 410 | |
| 411 | it('lässt die vorhandene Zieldatei unangetastet, wenn das Schreiben scheitert', () => { |
| 412 | /* Eine Quelle ohne die Pflichttabellen: Die Gegenprobe in |
| 413 | `sicherungSchreiben` lehnt das Ergebnis ab und wirft. */ |
| 414 | const untauglich = new Database(join(ordner, 'untauglich.db')); |
| 415 | offene.push(untauglich); |
| 416 | untauglich.exec('CREATE TABLE irgendwas (a INTEGER)'); |
| 417 | |
| 418 | const ziel = join(ordner, 'sicherung.wsklernstand'); |
| 419 | writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8'); |
| 420 | |
| 421 | expect(() => sicherungSchreiben(untauglich, ziel, Database)).toThrow(); |
| 422 | expect(readFileSync(ziel, 'utf8')).toBe('ALTE SICHERUNG'); |
| 423 | expect(existsSync(`${ziel}.teil`)).toBe(false); |
| 424 | }); |
| 425 | |
| 426 | /* |
| 427 | `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit |
| 428 | `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie |
| 429 | aufgeschlagen wurden. Sie gehören in die Historie, aber nicht in eine |
| 430 | Zahl, über die jemand einen nicht rücknehmbaren Schritt freigibt. |
| 431 | */ |
| 432 | it('zählt nie aufgeschlagene Prüfungsfragen nicht als Antworten', () => { |
| 433 | const pfad = join(ordner, 'mit-historie.db'); |
| 434 | const db = lernstandAnlegen(pfad, 5); |
| 435 | db.exec('ALTER TABLE antwort_log ADD COLUMN nur_historie INTEGER NOT NULL DEFAULT 0'); |
| 436 | db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run( |
| 437 | '2026-08-20T09:00:00.000Z', |
| 438 | ); |
| 439 | db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run( |
| 440 | '2026-08-21T09:00:00.000Z', |
| 441 | ); |
| 442 | |
| 443 | const kennzahlen = kennzahlenLesen(db, SCHEMA_VERSION, new Set(['profil', 'antwort_log'])); |
| 444 | |
| 445 | expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 7 }); |
| 446 | expect(kennzahlen.antworten).toBe(5); |
| 447 | }); |
| 448 | |
| 449 | it('zählt bei älteren Sicherungen ohne die Spalte weiterhin alles', () => { |
| 450 | /* Rückwirkend liesse sich nicht ermitteln, welche Zeile nie gestellt |
| 451 | wurde. Zu raten wäre schlechter, als es stehen zu lassen. */ |
| 452 | const pfad = join(ordner, 'alt.db'); |
| 453 | const db = lernstandAnlegen(pfad, 4); |
| 454 | |
| 455 | const kennzahlen = kennzahlenLesen(db, 7, new Set(['profil', 'antwort_log'])); |
| 456 | |
| 457 | expect(kennzahlen.antworten).toBe(4); |
| 458 | }); |
| 459 | }); |