waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | // @vitest-environment node |
| 2 | // Schema-Schutzgitter. Geprüft wird gegen echte SQLite-Dateien, weil genau |
| 3 | // die Eigenschaften auf dem Spiel stehen, die eine In-Memory-Datenbank nicht |
| 4 | // zeigt: WAL-Journal, Fremdschlüssel über Neustarts hinweg, Dauerhaftigkeit. |
| 5 | |
| 6 | import { mkdtempSync, rmSync } from 'node:fs'; |
| 7 | import { tmpdir } from 'node:os'; |
| 8 | import { join } from 'node:path'; |
| 9 | |
| 10 | import Database from 'better-sqlite3'; |
| 11 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 12 | |
| 13 | import { Lernstand, lernstandInstanz, lernstandSchliessen } from '../src/main/lernstand'; |
| 14 | import { SCHEMA_VERSION } from '../src/main/schema'; |
| 15 | import type { Frage, Katalog } from '../src/shared/katalog'; |
| 16 | |
| 17 | const FRAGEN: readonly Frage[] = [ |
| 18 | { |
| 19 | id: 'I.1-01', |
| 20 | amtliche_nummer: '1.01', |
| 21 | kapitel: 'I', |
| 22 | abschnitt: 'I.1', |
| 23 | typ: 'mc', |
| 24 | seite: 1, |
| 25 | frage: { text: 'Frage', segmente: [{ t: 'Frage' }] }, |
| 26 | bilder: [], |
| 27 | optionen: [ |
| 28 | { label: 'a', inhalt: { text: 'a', segmente: [] }, korrekt: true, bilder: [] }, |
| 29 | { label: 'b', inhalt: { text: 'b', segmente: [] }, korrekt: false, bilder: [] }, |
| 30 | ], |
| 31 | }, |
| 32 | { |
| 33 | id: 'II-01', |
| 34 | amtliche_nummer: '2.01', |
| 35 | kapitel: 'II', |
| 36 | abschnitt: null, |
| 37 | typ: 'freitext', |
| 38 | seite: 2, |
| 39 | frage: { text: 'Frage', segmente: [{ t: 'Frage' }] }, |
| 40 | bilder: [], |
| 41 | musterantwort: { text: 'Antwort', segmente: [{ t: 'Antwort', h: true }] }, |
| 42 | }, |
| 43 | ]; |
| 44 | |
| 45 | const KATALOG: Katalog = { |
| 46 | meta: { |
| 47 | titel: 'Prüfkatalog', |
| 48 | herausgeber: 'Bundesverwaltungsamt', |
| 49 | stand: '2024-12-16', |
| 50 | quellenangabe: 'Amtlicher Fragenkatalog.', |
| 51 | quelle_url: 'https://www.bva.bund.de/', |
| 52 | quelldatei_sha256: 'a'.repeat(64), |
| 53 | fragen_gesamt: FRAGEN.length, |
| 54 | }, |
| 55 | kapitel: [ |
| 56 | { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }, |
| 57 | { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, |
| 58 | ], |
| 59 | bilder: [], |
| 60 | fragen: FRAGEN, |
| 61 | }; |
| 62 | |
| 63 | let verzeichnis: string; |
| 64 | let pfad: string; |
| 65 | /** Alle geöffneten Verbindungen – auch die, deren Aufbau scheiterte. */ |
| 66 | let verbindungen: Database.Database[]; |
| 67 | |
| 68 | function oeffnen(): { lernstand: Lernstand; db: Database.Database } { |
| 69 | const db = new Database(pfad); |
| 70 | // Vor der Konstruktion vormerken: schlägt sie fehl, muss die Verbindung |
| 71 | // trotzdem geschlossen werden, sonst bleibt die Datei unter Windows gesperrt. |
| 72 | verbindungen.push(db); |
| 73 | return { lernstand: new Lernstand(db, KATALOG), db }; |
| 74 | } |
| 75 | |
| 76 | beforeEach(() => { |
| 77 | verzeichnis = mkdtempSync(join(tmpdir(), 'waffensachkunde-')); |
| 78 | pfad = join(verzeichnis, 'lernstand.db'); |
| 79 | verbindungen = []; |
| 80 | }); |
| 81 | |
| 82 | afterEach(() => { |
| 83 | // Die Modul-weite Instanz aus lernstandInstanz mit aufraeumen. |
| 84 | lernstandSchliessen(); |
| 85 | for (const db of verbindungen) { |
| 86 | if (db.open) { |
| 87 | db.close(); |
| 88 | } |
| 89 | } |
| 90 | rmSync(verzeichnis, { recursive: true, force: true }); |
| 91 | }); |
| 92 | |
| 93 | // ─── Aufbau ───────────────────────────────────────────────────────────────── |
| 94 | |
| 95 | describe('Schema', () => { |
| 96 | it('legt alle geforderten Tabellen an', () => { |
| 97 | const { db } = oeffnen(); |
| 98 | |
| 99 | const tabellen = ( |
| 100 | db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { |
| 101 | name: string; |
| 102 | }[] |
| 103 | ).map((z) => z.name); |
| 104 | |
| 105 | expect(tabellen).toEqual( |
| 106 | expect.arrayContaining([ |
| 107 | 'schema_version', |
| 108 | 'profil', |
| 109 | 'frage_stand', |
| 110 | 'antwort_log', |
| 111 | 'katalog_stand', |
| 112 | ]), |
| 113 | ); |
| 114 | }); |
| 115 | |
| 116 | it.each([ |
| 117 | ['profil', ['id', 'name', 'pruefungstermin', 'erstellt_am']], |
| 118 | [ |
| 119 | 'frage_stand', |
| 120 | [ |
| 121 | 'profil_id', |
| 122 | 'frage_id', |
| 123 | 'versuche', |
| 124 | 'richtige', |
| 125 | 'zuletzt_beantwortet', |
| 126 | 'faellig_ab', |
| 127 | 'gemerkt', |
| 128 | 'letzte_bewertung', |
| 129 | ], |
| 130 | ], |
| 131 | [ |
| 132 | 'antwort_log', |
| 133 | [ |
| 134 | 'id', |
| 135 | 'profil_id', |
| 136 | 'frage_id', |
| 137 | 'zeitpunkt', |
| 138 | 'richtig', |
| 139 | 'bewertung', |
| 140 | 'dauer_ms', |
| 141 | 'auswahl', |
| 142 | 'freitext', |
| 143 | ], |
| 144 | ], |
| 145 | ])('gibt der Tabelle %s die vereinbarten Spalten', (tabelle, spalten) => { |
| 146 | const { db } = oeffnen(); |
| 147 | |
| 148 | const vorhanden = (db.pragma(`table_info(${tabelle})`) as { name: string }[]).map( |
| 149 | (s) => s.name, |
| 150 | ); |
| 151 | |
| 152 | expect(vorhanden).toEqual(expect.arrayContaining(spalten)); |
| 153 | }); |
| 154 | |
| 155 | it('nutzt (profil_id, frage_id) als Primärschlüssel von frage_stand', () => { |
| 156 | const { db } = oeffnen(); |
| 157 | |
| 158 | const schluessel = (db.pragma('table_info(frage_stand)') as { name: string; pk: number }[]) |
| 159 | .filter((s) => s.pk > 0) |
| 160 | .sort((a, b) => a.pk - b.pk) |
| 161 | .map((s) => s.name); |
| 162 | |
| 163 | expect(schluessel).toEqual(['profil_id', 'frage_id']); |
| 164 | }); |
| 165 | |
| 166 | it('schaltet WAL und Fremdschlüssel ein', () => { |
| 167 | const { db } = oeffnen(); |
| 168 | |
| 169 | expect(db.pragma('journal_mode', { simple: true })).toBe('wal'); |
| 170 | expect(db.pragma('foreign_keys', { simple: true })).toBe(1); |
| 171 | }); |
| 172 | |
| 173 | it('hält den Schemastand fest', () => { |
| 174 | const { db } = oeffnen(); |
| 175 | |
| 176 | expect(db.prepare('SELECT MAX(version) AS version FROM schema_version').get()).toEqual({ |
| 177 | version: SCHEMA_VERSION, |
| 178 | }); |
| 179 | }); |
| 180 | }); |
| 181 | |
| 182 | // ─── Beständigkeit ────────────────────────────────────────────────────────── |
| 183 | |
| 184 | describe('Beständigkeit', () => { |
| 185 | it('behält Profile und Antworten über einen Neustart hinweg', () => { |
| 186 | const erste = oeffnen(); |
| 187 | const profil = erste.lernstand.profilAnlegen('Olaf'); |
| 188 | erste.lernstand.antworten(profil.id, { |
| 189 | frageId: 'I.1-01', |
| 190 | auswahl: ['a'], |
| 191 | richtig: true, |
| 192 | bewertung: 'gut', |
| 193 | dauerMs: 1200, |
| 194 | }); |
| 195 | erste.lernstand.schliessen(); |
| 196 | |
| 197 | const zweite = oeffnen(); |
| 198 | |
| 199 | expect(zweite.lernstand.profile().map((p) => p.name)).toEqual(['Standard', 'Olaf']); |
| 200 | expect(zweite.lernstand.frageStand(profil.id, 'I.1-01')).toMatchObject({ |
| 201 | versuche: 1, |
| 202 | richtige: 1, |
| 203 | letzteBewertung: 'gut', |
| 204 | }); |
| 205 | }); |
| 206 | |
| 207 | it('legt beim erneuten Öffnen weder ein zweites Standardprofil noch einen zweiten Schemaeintrag an', () => { |
| 208 | oeffnen().lernstand.schliessen(); |
| 209 | const { lernstand, db } = oeffnen(); |
| 210 | |
| 211 | expect(lernstand.profile()).toHaveLength(1); |
| 212 | expect(db.prepare('SELECT COUNT(*) AS n FROM schema_version').get()).toEqual({ |
| 213 | n: SCHEMA_VERSION, |
| 214 | }); |
| 215 | /* Auch der Katalogstand wird nur beim ersten Öffnen vermerkt – bei |
| 216 | Gleichstand entsteht keine zweite Zeile, sonst wüchse die Tabelle mit |
| 217 | jedem Start und der Verlauf sagte nichts mehr. */ |
| 218 | expect(db.prepare('SELECT COUNT(*) AS n FROM katalog_stand').get()).toEqual({ n: 1 }); |
| 219 | }); |
| 220 | |
| 221 | it('verweigert den Start, wenn der Lernstand aus einer neueren Version stammt', () => { |
| 222 | const { lernstand, db } = oeffnen(); |
| 223 | db.prepare('INSERT INTO schema_version (version, angewendet_am) VALUES (?, ?)').run( |
| 224 | SCHEMA_VERSION + 5, |
| 225 | new Date().toISOString(), |
| 226 | ); |
| 227 | lernstand.schliessen(); |
| 228 | |
| 229 | expect(() => oeffnen()).toThrow(/neueren Programmversion/u); |
| 230 | }); |
| 231 | |
| 232 | it('lässt nach einer Abweisung keine offene Verbindung zurück', () => { |
| 233 | /* Scheitert der Konstruktor, bliebe die eben geöffnete Verbindung sonst |
| 234 | liegen – und die Oberfläche versucht es nach jeder Sitzung erneut. |
| 235 | Unter Windows ist eine Datei mit offenem Handle nicht löschbar; genau |
| 236 | das ist hier der belastbare Nachweis. */ |
| 237 | const { lernstand, db } = oeffnen(); |
| 238 | db.prepare('INSERT INTO schema_version (version, angewendet_am) VALUES (?, ?)').run( |
| 239 | SCHEMA_VERSION + 5, |
| 240 | new Date().toISOString(), |
| 241 | ); |
| 242 | lernstand.schliessen(); |
| 243 | |
| 244 | for (let versuch = 0; versuch < 3; versuch += 1) { |
| 245 | expect(() => lernstandInstanz(pfad, KATALOG)).toThrow(/neueren Programmversion/u); |
| 246 | } |
| 247 | |
| 248 | expect(() => { |
| 249 | rmSync(pfad, { force: true }); |
| 250 | }).not.toThrow(); |
| 251 | }); |
| 252 | }); |
| 253 | |
| 254 | // ─── Datenintegrität ──────────────────────────────────────────────────────── |
| 255 | |
| 256 | describe('Integritätsregeln', () => { |
| 257 | it('lehnt Protokollzeilen zu einem unbekannten Profil ab', () => { |
| 258 | const { db } = oeffnen(); |
| 259 | |
| 260 | expect(() => |
| 261 | db |
| 262 | .prepare( |
| 263 | `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) |
| 264 | VALUES (?, ?, ?, ?, ?, ?)`, |
| 265 | ) |
| 266 | .run(4711, 'I.1-01', new Date().toISOString(), 1, 'gut', 100), |
| 267 | ).toThrow(/FOREIGN KEY/u); |
| 268 | }); |
| 269 | |
| 270 | it('räumt beim Löschen eines Profils dessen Daten mit ab', () => { |
| 271 | const { lernstand, db } = oeffnen(); |
| 272 | const profil = lernstand.profilAnlegen('Olaf'); |
| 273 | lernstand.antworten(profil.id, { |
| 274 | frageId: 'I.1-01', |
| 275 | auswahl: ['a'], |
| 276 | richtig: true, |
| 277 | bewertung: 'gut', |
| 278 | dauerMs: 10, |
| 279 | }); |
| 280 | |
| 281 | db.prepare('DELETE FROM profil WHERE id = ?').run(profil.id); |
| 282 | |
| 283 | expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 }); |
| 284 | expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 }); |
| 285 | }); |
| 286 | |
| 287 | it('lässt je Profil und Frage nur eine Standzeile zu', () => { |
| 288 | const { lernstand, db } = oeffnen(); |
| 289 | const profilId = lernstand.profile()[0]!.id; |
| 290 | lernstand.merken(profilId, 'I.1-01', true); |
| 291 | |
| 292 | expect(() => |
| 293 | db |
| 294 | .prepare('INSERT INTO frage_stand (profil_id, frage_id) VALUES (?, ?)') |
| 295 | .run(profilId, 'I.1-01'), |
| 296 | ).toThrow(/UNIQUE|PRIMARY KEY/u); |
| 297 | }); |
| 298 | |
| 299 | it.each([ |
| 300 | ['bewertung', 'auswendig'], |
| 301 | ['richtig', 7], |
| 302 | ])('lehnt einen unzulässigen Wert in antwort_log.%s ab', (spalte, wert) => { |
| 303 | const { lernstand, db } = oeffnen(); |
| 304 | const profilId = lernstand.profile()[0]!.id; |
| 305 | |
| 306 | const werte: Record<string, string | number> = { |
| 307 | profil_id: profilId, |
| 308 | frage_id: 'I.1-01', |
| 309 | zeitpunkt: new Date().toISOString(), |
| 310 | richtig: 1, |
| 311 | bewertung: 'gut', |
| 312 | dauer_ms: 10, |
| 313 | }; |
| 314 | werte[spalte] = wert; |
| 315 | |
| 316 | expect(() => |
| 317 | db |
| 318 | .prepare( |
| 319 | `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) |
| 320 | VALUES (@profil_id, @frage_id, @zeitpunkt, @richtig, @bewertung, @dauer_ms)`, |
| 321 | ) |
| 322 | .run(werte), |
| 323 | ).toThrow(/CHECK/u); |
| 324 | }); |
| 325 | |
| 326 | it('lehnt einen doppelten Profilnamen auch auf Datenbankebene ab', () => { |
| 327 | const { db } = oeffnen(); |
| 328 | |
| 329 | expect(() => |
| 330 | db |
| 331 | .prepare('INSERT INTO profil (name, erstellt_am) VALUES (?, ?)') |
| 332 | .run('Standard', new Date().toISOString()), |
| 333 | ).toThrow(/UNIQUE/u); |
| 334 | }); |
| 335 | }); |
| 336 | |
| 337 | describe('Migration auf einen bestehenden Lernstand', () => { |
| 338 | /* |
| 339 | Der Fall, den kein bisheriger Test abdeckte – und der genau deshalb |
| 340 | gefährlich ist: Alle Tests legen frische Datenbanken an, und auf einer |
| 341 | frischen tut `CREATE TABLE IF NOT EXISTS` alles Nötige. Auf einer |
| 342 | bestehenden ist es ein reiner Leerlauf. Wer eine neue Spalte dort statt in |
| 343 | MIGRATIONEN einträgt, baut einen Fehler, den die ganze Suite nicht sieht: |
| 344 | Die Spalte entsteht nie, und das erste SELECT darauf scheitert mit |
| 345 | „no such column“ – beim Nutzer, nicht im Test. |
| 346 | */ |
| 347 | it('ergänzt kapitel_ausschluss auf einer Datenbank ohne diese Spalte', () => { |
| 348 | const { lernstand } = oeffnen(); |
| 349 | lernstand.schliessen(); |
| 350 | |
| 351 | /* Den Zustand vor der Migration nachstellen: Spalte weg, Versionsstand |
| 352 | zurück. SQLite kann DROP COLUMN seit 3.35. */ |
| 353 | const roh = new Database(pfad); |
| 354 | verbindungen.push(roh); |
| 355 | roh.exec('ALTER TABLE profil DROP COLUMN kapitel_ausschluss'); |
| 356 | roh.prepare('DELETE FROM schema_version WHERE version >= 6').run(); |
| 357 | expect( |
| 358 | roh |
| 359 | .prepare<[], { name: string }>('PRAGMA table_info(profil)') |
| 360 | .all() |
| 361 | .some((z) => z.name === 'kapitel_ausschluss'), |
| 362 | ).toBe(false); |
| 363 | roh.close(); |
| 364 | |
| 365 | const zweite = oeffnen(); |
| 366 | |
| 367 | /* Die Spalte ist da, gefüllt, und das bestehende Profil ist unverändert |
| 368 | lesbar – ohne Vorgabewert scheiterte das ADD COLUMN an NOT NULL. */ |
| 369 | expect(zweite.lernstand.profile()[0]?.kapitelAusschluss).toEqual([]); |
| 370 | expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({ |
| 371 | v: SCHEMA_VERSION, |
| 372 | }); |
| 373 | }); |
| 374 | |
| 375 | it('bildet den belegten Abruf aus dem Antwortprotokoll exakt nach', () => { |
| 376 | /* |
| 377 | Der Punkt, an dem eine Migration entweder ehrlich ist oder rät. |
| 378 | |
| 379 | Der Beleg ist neu – aber er lässt sich nicht schätzen, sondern |
| 380 | nachspielen: `antwort_log` ist die vollständige Historie und wird nie |
| 381 | überschrieben; der Kommentar über `frage_stand` im Schema sagt selbst, |
| 382 | dort stehe nur die Zusammenfassung, „die Wahrheit steht in antwort_log“. |
| 383 | |
| 384 | Dieser Test schreibt eine Historie von Hand, nimmt die Spalte weg, öffnet |
| 385 | neu – und verlangt, dass genau die Fragen belegt sind, die es nach der |
| 386 | Regel sein müssen. Ein Vorgabewert (etwa „alles, was zuletzt richtig |
| 387 | war“) käme durch die anderen Tests durch und wäre trotzdem falsch. |
| 388 | */ |
| 389 | const { lernstand, db } = oeffnen(); |
| 390 | const profil = lernstand.profile()[0]!; |
| 391 | lernstand.schliessen(); |
| 392 | |
| 393 | const roh = new Database(pfad); |
| 394 | verbindungen.push(roh); |
| 395 | |
| 396 | /* Vier Fragen, vier Verläufe. Die erwarteten Ergebnisse stehen daneben. */ |
| 397 | const historie: readonly (readonly [string, number, string])[] = [ |
| 398 | // I.1-01: einmal richtig – kein Beleg, das erste Mal zählt nie. |
| 399 | ['I.1-01', 1, '2026-01-01T09:00:00.000Z'], |
| 400 | // I.1-02: richtig, dann zwei Tage später wieder richtig – belegt. |
| 401 | ['I.1-02', 1, '2026-01-01T09:00:00.000Z'], |
| 402 | ['I.1-02', 1, '2026-01-03T09:00:00.000Z'], |
| 403 | // I.2-01: wie oben belegt, danach aber falsch – Beleg wieder weg. |
| 404 | ['I.2-01', 1, '2026-01-01T09:00:00.000Z'], |
| 405 | ['I.2-01', 1, '2026-01-03T09:00:00.000Z'], |
| 406 | ['I.2-01', 0, '2026-01-10T09:00:00.000Z'], |
| 407 | // II-01: zweimal richtig am selben Tag – kein Beleg, kein Schaden. |
| 408 | ['II-01', 1, '2026-01-01T09:00:00.000Z'], |
| 409 | ['II-01', 1, '2026-01-01T21:00:00.000Z'], |
| 410 | ]; |
| 411 | |
| 412 | const eintragen = roh.prepare( |
| 413 | `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) |
| 414 | VALUES (?, ?, ?, ?, ?, 1500)`, |
| 415 | ); |
| 416 | const stand = roh.prepare( |
| 417 | `INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, zuletzt_beantwortet) |
| 418 | VALUES (?, ?, 1, 1, ?) |
| 419 | ON CONFLICT (profil_id, frage_id) DO UPDATE SET versuche = versuche + 1`, |
| 420 | ); |
| 421 | for (const [frageId, richtig, zeitpunkt] of historie) { |
| 422 | eintragen.run(profil.id, frageId, zeitpunkt, richtig, richtig === 1 ? 'gut' : 'nochmal'); |
| 423 | stand.run(profil.id, frageId, zeitpunkt); |
| 424 | } |
| 425 | |
| 426 | roh.exec('ALTER TABLE frage_stand DROP COLUMN bestaetigt'); |
| 427 | roh.prepare('DELETE FROM schema_version WHERE version >= 7').run(); |
| 428 | roh.close(); |
| 429 | |
| 430 | const zweite = oeffnen(); |
| 431 | const belegt = zweite.db |
| 432 | .prepare<[], { frage_id: string }>( |
| 433 | 'SELECT frage_id FROM frage_stand WHERE bestaetigt = 1 ORDER BY frage_id', |
| 434 | ) |
| 435 | .all() |
| 436 | .map((z) => z.frage_id); |
| 437 | |
| 438 | expect(belegt).toEqual(['I.1-02']); |
| 439 | expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({ |
| 440 | v: SCHEMA_VERSION, |
| 441 | }); |
| 442 | void db; |
| 443 | }); |
| 444 | |
| 445 | it('ergänzt nur_historie und lässt bestehende Zeilen mitzählen', () => { |
| 446 | /* |
| 447 | Schemafassung 8. Rückwirkend lässt sich nicht ermitteln, welche |
| 448 | Protokollzeilen zu nie gestellten Prüfungsfragen gehören – sie bekommen |
| 449 | deshalb 0 und zählen weiter mit. Das ist die ehrlichere Wahl: Zu raten |
| 450 | hiesse, Zahlen zu ändern, von denen niemand weiss, ob sie falsch waren. |
| 451 | */ |
| 452 | const { lernstand } = oeffnen(); |
| 453 | const profil = lernstand.profile()[0]!; |
| 454 | lernstand.antworten(profil.id, { |
| 455 | frageId: 'I.1-01', |
| 456 | auswahl: ['a'], |
| 457 | richtig: true, |
| 458 | bewertung: 'gut', |
| 459 | dauerMs: 1000, |
| 460 | }); |
| 461 | lernstand.schliessen(); |
| 462 | |
| 463 | const roh = new Database(pfad); |
| 464 | verbindungen.push(roh); |
| 465 | roh.exec('ALTER TABLE antwort_log DROP COLUMN nur_historie'); |
| 466 | roh.prepare('DELETE FROM schema_version WHERE version >= 8').run(); |
| 467 | roh.close(); |
| 468 | |
| 469 | const zweite = oeffnen(); |
| 470 | |
| 471 | expect( |
| 472 | zweite.db |
| 473 | .prepare<[], { anzahl: number }>( |
| 474 | 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0', |
| 475 | ) |
| 476 | .get()?.anzahl, |
| 477 | ).toBe(1); |
| 478 | /* Und die bestehende Zeile zählt weiterhin in die Tagesbilanz. */ |
| 479 | expect(zweite.lernstand.uebersicht(profil.id).heuteRichtig).toBe(1); |
| 480 | }); |
| 481 | |
| 482 | it('vermerkt beim Aufstieg auf Fassung 9 den geladenen Katalogstand als Ausgangswert', () => { |
| 483 | /* |
| 484 | Schemafassung 9. Gegen welchen Katalogstand die vorhandenen Zeilen |
| 485 | wirklich entstanden, wurde nie festgehalten und lässt sich nicht |
| 486 | rekonstruieren – der geladene Stand ist der ehrlichste Ausgangswert. |
| 487 | Und er darf keine Meldung auslösen: Für den Nutzer hat sich an diesem |
| 488 | Tag nichts geändert, ein Alarm wäre erfunden. |
| 489 | */ |
| 490 | const { lernstand } = oeffnen(); |
| 491 | lernstand.schliessen(); |
| 492 | |
| 493 | const roh = new Database(pfad); |
| 494 | verbindungen.push(roh); |
| 495 | roh.exec('DROP TABLE katalog_stand'); |
| 496 | roh.prepare('DELETE FROM schema_version WHERE version >= 9').run(); |
| 497 | roh.close(); |
| 498 | |
| 499 | const zweite = oeffnen(); |
| 500 | |
| 501 | expect(zweite.lernstand.katalogwechsel).toBeNull(); |
| 502 | expect( |
| 503 | zweite.db |
| 504 | .prepare<[], { stand: string }>('SELECT stand FROM katalog_stand ORDER BY id DESC LIMIT 1') |
| 505 | .get(), |
| 506 | ).toEqual({ stand: '2024-12-16' }); |
| 507 | expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({ |
| 508 | v: SCHEMA_VERSION, |
| 509 | }); |
| 510 | }); |
| 511 | |
| 512 | it('läuft ein zweites Mal durch, ohne zu scheitern', () => { |
| 513 | /* Ein nacktes ALTER TABLE würfe beim zweiten Lauf „duplicate column |
| 514 | name“, und eine einmal aus dem Tritt geratene Datenbank wäre dauerhaft |
| 515 | nicht mehr zu öffnen. `spalteErgaenzen` sieht deshalb erst nach. */ |
| 516 | const { lernstand } = oeffnen(); |
| 517 | lernstand.schliessen(); |
| 518 | |
| 519 | const roh = new Database(pfad); |
| 520 | verbindungen.push(roh); |
| 521 | roh.prepare('DELETE FROM schema_version WHERE version >= 6').run(); |
| 522 | roh.close(); |
| 523 | |
| 524 | expect(() => oeffnen()).not.toThrow(); |
| 525 | }); |
| 526 | }); |