//! Abgleich `docs/inventar.md` ↔ Implementierungsstand. //! //! Das Inventar ist die Sollseite der Leitplanke „Vollständigkeit ist das //! Soll"; dieser Test hält es gegen den Code, damit der Status nicht //! verrottet. Prüfregeln siehe `pruefe_status`. //! //! ponytail: Die Tabellen `builtin_fn`/`builtin_stmt`/`banned_feature` sind //! private `match`-Ausdrücke ohne Namensliste. Statt sie öffentlich zu machen //! oder die Namen ein zweites Mal zu pflegen, liest der Test die Stringliterale //! aus dem Quelltext. Ceiling: Wird `sema.rs` umstrukturiert, schlägt //! `abschnitt` fehl und nennt den fehlenden Anker — dann hier nachziehen oder //! die Tabellen als `pub const`-Listen exportieren. use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; const IMPLEMENTIERT: &str = "implementiert"; const OFFEN: &str = "offen"; const NON_FEATURE: &str = "Non-Feature"; fn wurzel() -> PathBuf { // crates/tb-cli/ → Projektwurzel PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } // ---- Inventar lesen -------------------------------------------------------- #[derive(Debug, Clone)] struct Eintrag { name: String, art: String, status: String, fundstelle: String, quelle: String, zeile: usize, } fn inventar() -> Vec { let pfad = wurzel().join("docs/inventar.md"); let text = std::fs::read_to_string(&pfad) .unwrap_or_else(|e| panic!("docs/inventar.md nicht lesbar: {e}")); let mut out = Vec::new(); for (i, zeile) in text.lines().enumerate() { let zeile = zeile.trim(); if !zeile.starts_with("| `") { continue; } let spalten: Vec<&str> = zeile .trim_matches('|') .split('|') .map(|s| s.trim()) .collect(); assert_eq!( spalten.len(), 6, "docs/inventar.md Zeile {}: {} Spalten statt 6 — {zeile}", i + 1, spalten.len() ); out.push(Eintrag { name: spalten[0].trim_matches('`').to_string(), art: spalten[1].to_string(), status: spalten[3].to_string(), fundstelle: spalten[4].to_string(), quelle: spalten[5].to_string(), zeile: i + 1, }); } assert!(!out.is_empty(), "docs/inventar.md enthält keine Einträge"); out } // ---- Implementierungsstand aus sema.rs ------------------------------------- fn sema_quelle() -> String { let pfad = wurzel().join("crates/tb-frontend/src/sema.rs"); std::fs::read_to_string(&pfad).unwrap_or_else(|e| panic!("sema.rs nicht lesbar: {e}")) } /// Quelltextabschnitt zwischen zwei Ankern (exklusive Endanker). fn abschnitt<'a>(quelle: &'a str, von: &str, bis: &str) -> &'a str { let a = quelle .find(von) .unwrap_or_else(|| panic!("Anker nicht gefunden in sema.rs: {von}")); let rest = &quelle[a..]; let b = rest .find(bis) .unwrap_or_else(|| panic!("Endanker nicht gefunden in sema.rs: {bis}")); &rest[..b] } /// Alle Stringliterale eines Abschnitts. fn literale(abschnitt: &str) -> BTreeSet { let mut out = BTreeSet::new(); let bytes: Vec = abschnitt.chars().collect(); let mut i = 0; while i < bytes.len() { if bytes[i] == '"' { let mut s = String::new(); i += 1; while i < bytes.len() && bytes[i] != '"' { s.push(bytes[i]); i += 1; } if !s.is_empty() { out.insert(s); } } i += 1; } out } struct Stand { signaturen: BTreeSet, verboten: BTreeSet, nicht_verfuegbar: BTreeSet, /// Non-Features, die an ihrer Syntax statt an ihrem Namen erkannt werden. syntaktisch: BTreeSet, } fn stand() -> Stand { let q = sema_quelle(); let fn_tab = abschnitt(&q, "fn builtin_fn(", "/// Deklarierte Non-Features"); let ban = abschnitt( &q, "fn banned_feature(", "/// Non-Features, die nicht über ihren Namen", ); let stmt_tab = abschnitt(&q, "fn builtin_stmt(", "// ---- Einstiegspunkte"); let unsup_fn = abschnitt( &q, "// --- Spätere Phasen: dokumentiert", "(HExpr::Unsupported(\"Funktion\")", ); let unsup_stmt = abschnitt( &q, "// Bildschirm-/Datei-/System-Anweisungen späterer Phasen.", r#"HStmtKind::Unsupported("Anweisung")"#, ); let mut signaturen = literale(fn_tab); signaturen.extend(literale(stmt_tab)); let mut nicht_verfuegbar = literale(unsup_fn); nicht_verfuegbar.extend(literale(unsup_stmt)); // Platzhalter der Sammelzweige sind keine Elementnamen. nicht_verfuegbar.remove("Funktion"); nicht_verfuegbar.remove("Anweisung"); let syn = abschnitt( &q, "pub const SYNTAKTISCH_ABGEWIESEN", "/// Ist `s` ein serieller Gerätename", ); Stand { signaturen, verboten: literale(ban), nicht_verfuegbar, syntaktisch: literale(syn) .into_iter() .map(|s| s.to_uppercase()) .collect(), } } // ---- Regeln ---------------------------------------------------------------- #[test] fn statusvokabular_ist_beschraenkt() { for e in inventar() { assert!( matches!( e.art.as_str(), "Anweisung" | "Funktion" | "Metabefehl" | "Routine" | "Eigenschaft" | "Methode" | "Ereignis" | "Datentyp" | "Operator" ), "Zeile {}: `{}` trägt unzulässige Art `{}`", e.zeile, e.name, e.art ); assert!( matches!(e.status.as_str(), IMPLEMENTIERT | OFFEN | NON_FEATURE), "Zeile {}: `{}` trägt unzulässigen Status `{}` \ (erlaubt: {IMPLEMENTIERT}/{OFFEN}/{NON_FEATURE})", e.zeile, e.name, e.status ); } } fn pruefe_forms(inv: &[Eintrag]) -> Vec { use tb_frontend::forms::{events, method_is_implemented, methods, properties, ObjectClass}; let mut erwartet = BTreeMap::new(); for class in ObjectClass::ALL { for p in properties(class) { erwartet.insert( format!("{}.{}", class.name(), p.name), ("Eigenschaft", IMPLEMENTIERT, "tb-ui::forms"), ); } for m in methods(class) { erwartet.insert( format!("{}.{m}", class.name()), if method_is_implemented(class, m) { ("Methode", IMPLEMENTIERT, "tb-ui::forms") } else { ("Methode", OFFEN, "-") }, ); } for e in events(class) { erwartet.insert( format!("{}_{e}", class.name()), ("Ereignis", IMPLEMENTIERT, "tb-vm::interp"), ); } } let gefunden: BTreeMap<_, _> = inv .iter() .filter(|e| matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis")) .map(|e| (e.name.to_uppercase(), e)) .collect(); let mut fehler = Vec::new(); for (name, (art, status, fundstelle)) in &erwartet { match gefunden.get(name) { None => fehler.push(format!("`{name}` fehlt")), Some(e) if e.art != *art || e.status != *status || e.fundstelle != *fundstelle || e.quelle != "forms-referenz" => { fehler.push(format!("`{name}`: Art/Status/Fundstelle/Quelle falsch")) } Some(_) => {} } } for name in gefunden.keys() { if !erwartet.contains_key(name) { fehler.push(format!("`{name}` ist nicht in der Klassentabelle")); } } fehler } #[test] fn forms_inventar_stimmt_mit_der_klassentabelle_ueberein() { let fehler = pruefe_forms(&inventar()); assert!( fehler.is_empty(), "Forms-Inventar weicht ab:\n {}", fehler.join("\n ") ); } #[test] fn falsch_gesetzte_eigenschaft_wird_erkannt() { let mut inv = inventar(); let e = inv .iter_mut() .find(|e| e.name == "TEXTBOX.SELSTART") .unwrap(); e.status = OFFEN.into(); assert!(pruefe_forms(&inv) .iter() .any(|f| f.contains("TEXTBOX.SELSTART"))); } #[test] fn non_features_nennen_eine_fundstelle_in_der_sprachreferenz() { for e in inventar() { if e.status == NON_FEATURE { assert!( e.fundstelle.contains("sprachreferenz.md"), "Zeile {}: `{}` ist Non-Feature, nennt aber keine Fundstelle \ in docs/sprachreferenz.md (gefunden: `{}`)", e.zeile, e.name, e.fundstelle ); } } } #[test] fn offene_elemente_haben_keine_fundstelle() { for e in inventar() { if e.status == OFFEN { assert_eq!( e.fundstelle, "-", "Zeile {}: `{}` ist offen, trägt aber die Fundstelle `{}`", e.zeile, e.name, e.fundstelle ); } } } /// Kernregel: Inventarstatus und Code dürfen nicht auseinanderlaufen. /// /// Geprüft wird nur, was mechanisch belegbar ist — die Bibliothekselemente aus /// `builtin_fn`/`builtin_stmt`, die Non-Feature-Liste und die /// „nicht verfügbar"-Marker. Anweisungen, die der Parser direkt kennt /// (`PRINT`, `IF`, …), und Metabefehle tauchen in keiner dieser Tabellen auf; /// für sie gilt nur die schwächere Regel `parserelemente_sind_keine_non_features`. #[test] fn inventar_stimmt_mit_code_ueberein() { let inv = inventar(); let st = stand(); let mut fehler: Vec = Vec::new(); // Ein Name kann zweimal vorkommen (Funktion und Anweisung, z. B. `SCREEN`). // Schlüssel ist der Name in Großschreibung — der Dialekt ist // case-insensitiv, der Code führt die Namen groß. // // ponytail: Einträge mit klammerndem Zusatz (`GET (Grafik)`) sind // syntaktische Varianten eines Tokens, das auch eine unterstützte Form hat. // Sie sind über den Namen nicht prüfbar und bleiben hier außen vor; ihre // Ablehnung liegt im Parser. Ceiling: sobald der Parser eine Liste seiner // abgewiesenen Formen exportiert, hier gegenprüfen. let mut je_name: BTreeMap> = BTreeMap::new(); for e in &inv { // Syntaktisch erkannte Formen tragen einen klammernden Zusatz oder // stehen namentlich in SYNTAKTISCH_ABGEWIESEN — sie werden unten // gegen diese Liste geprüft, nicht gegen `banned_feature`. if st.syntaktisch.contains(&e.name.to_uppercase()) { assert_eq!( e.status, NON_FEATURE, "Zeile {}: `{}` steht in SYNTAKTISCH_ABGEWIESEN, das Inventar sagt aber `{}`", e.zeile, e.name, e.status ); continue; } // Ein klammernder Zusatz benennt die Form (`KEY (Ereignis)`); für den // Codeabgleich zählt das Grundtoken davor. let schluessel = e .name .split('(') .next() .unwrap_or(&e.name) .trim() .to_uppercase(); je_name.entry(schluessel).or_default().push(e); } for (name, eintraege) in &je_name { let stati: BTreeSet<&str> = eintraege.iter().map(|e| e.status.as_str()).collect(); let _ = &name; let zeilen: Vec = eintraege.iter().map(|e| e.zeile.to_string()).collect(); let wo = format!("Zeile(n) {}", zeilen.join(", ")); let hat_signatur = st.signaturen.contains(name.as_str()); // `ON COM(1) GOSUB …` scheitert am Gerätenamen: für die ON-Formen // zählt das zweite Token. Andere Mehrwortnamen (`LINE INPUT`, // `VIEW PRINT`) sind eigenständige unterstützte Anweisungen und // erben nichts vom Grundtoken. let ist_verboten = match name.strip_prefix("ON ") { Some(geraet) => st.verboten.contains(geraet), None => st.verboten.contains(name.as_str()), }; let ist_marker = st.nicht_verfuegbar.contains(name.as_str()); if ist_verboten && !stati.contains(NON_FEATURE) { fehler.push(format!( "`{name}` ({wo}): Code weist es als Non-Feature ab, Inventar sagt {stati:?}" )); } if !ist_verboten && stati.contains(NON_FEATURE) { fehler.push(format!( "`{name}` ({wo}): Inventar sagt Non-Feature, Code weist es nicht ab \ (fehlt in banned_feature)" )); } if ist_marker && !stati.contains(OFFEN) { fehler.push(format!( "`{name}` ({wo}): senkt auf den „nicht verfügbar\"-Marker ab, \ Inventar sagt {stati:?}" )); } if stati == BTreeSet::from([IMPLEMENTIERT]) && ist_marker { fehler.push(format!( "`{name}` ({wo}): Inventar sagt implementiert, senkt aber weiterhin \ auf den „nicht verfügbar\"-Marker ab" )); } // Nur wenn ALLE Formen dieses Namens offen sind — sonst erklärt die // implementierte Form (etwa `DATE$` als Funktion) die Signatur. if stati == BTreeSet::from([OFFEN]) && hat_signatur && !ist_marker && !ist_verboten { fehler.push(format!( "`{name}` ({wo}): Inventar sagt offen, aber der Code hat eine Signatur \ ohne „nicht verfügbar\"-Marker — entweder implementiert oder Marker fehlt" )); } } // Gegenrichtung: kennt der Code Elemente, die das Inventar nicht führt? // Ein Element darf im Code unter mehreren Schreibweisen stehen: die // Original-Hilfe schreibt die Finanzfunktionen mit Typsuffix (`FV#`), der // Code nimmt beide an. Für den Abgleich zählt das Inventar als getroffen, // wenn eine der Schreibweisen passt. let bekannt = |n: &str| { let up = n.to_uppercase(); je_name.contains_key(&up) || ['#', '!', '&', '%', '@', '$'] .iter() .any(|s| je_name.contains_key(&format!("{up}{s}"))) }; for name in st.signaturen.iter().chain(st.verboten.iter()) { if !bekannt(name) { fehler.push(format!( "`{name}`: im Code bekannt, fehlt aber in docs/inventar.md" )); } } assert!( fehler.is_empty(), "Inventar und Code laufen auseinander ({} Abweichungen):\n {}", fehler.len(), fehler.join("\n ") ); } #[test] fn abdeckungsstand_wird_ausgewiesen() { let inv = inventar(); let zaehle = |s: &str| inv.iter().filter(|e| e.status == s).count(); let (i, o, n) = (zaehle(IMPLEMENTIERT), zaehle(OFFEN), zaehle(NON_FEATURE)); println!( "Abdeckung: implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}", inv.len() ); for (name, forms) in [("Sprache", false), ("Forms", true)] { let entries: Vec<_> = inv .iter() .filter(|e| matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis") == forms) .collect(); let count = |status: &str| entries.iter().filter(|e| e.status == status).count(); println!( "{name}: implementiert {} · offen {} · Non-Feature {} · gesamt {}", count(IMPLEMENTIERT), count(OFFEN), count(NON_FEATURE), entries.len() ); } let offene: Vec<_> = inv .iter() .filter(|e| e.status == OFFEN) .map(|e| e.name.as_str()) .collect(); println!( "Offene Einträge: {}", if offene.is_empty() { "keine".into() } else { offene.join(", ") } ); // Der Kopf der Tabelle nennt dieselben Zahlen — sonst ist die Doku stale. let text = std::fs::read_to_string(wurzel().join("docs/inventar.md")).unwrap(); let erwartet = format!( "implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}", inv.len() ); assert!( text.contains(&erwartet), "Abdeckungszeile in docs/inventar.md stimmt nicht; erwartet: {erwartet}" ); assert_eq!(i + o + n, inv.len(), "Statussumme ≠ Einträge"); } // ---- Namentliche Ablehnung ------------------------------------------------- /// Ein im Inventar geführtes Element darf nie als unbekannter Bezeichner oder /// mit einem generischen Syntaxfehler enden — die Ablehnung MUSS es nennen /// (Guiding Principle, Capability `sprachinventar`). /// /// Geprüft wird stichprobenartig je Ablehnungsweg, nicht über alle 285 /// Einträge: die vollständige Zuordnung leistet `inventar_stimmt_mit_code_ueberein`. #[test] fn dokumentierte_elemente_werden_namentlich_abgewiesen() { // (Quelltext, erwartetes Element, erwarteter Meldungsbestandteil) let faelle: &[(&str, &str, &str)] = &[ // Non-Feature über die Namensliste ("X = PEEK(100)", "PEEK", "Feature unavailable"), ("Y = POINT(1, 2)", "POINT", "Feature unavailable"), ("CALLS Foo", "CALLS", "Feature unavailable"), // Non-Feature über die Syntax (teilt das Schlüsselwort) ( "GET (1, 1)-(2, 2), A", "GET (Grafik)", "Feature unavailable", ), ( r#"OPEN "COM1:9600,N,8,1" AS #1"#, "OPEN COM", "Feature unavailable", ), // Ereignisgerät als Funktionsform ( "ON PEN GOSUB Ziel\nZiel:\nRETURN", "ON PEN", "Feature unavailable", ), ]; for (quelle, element, erwartet) in faelle { let a = tb_frontend::analyze_source("probe.bas", quelle); let texte: Vec = a.diagnostics.iter().map(|d| d.message.clone()).collect(); assert!( texte.iter().any(|m| m.contains(erwartet)), "`{element}`: erwartete Meldung `{erwartet}`, bekam {texte:?} \ (Quelle: {quelle:?})" ); assert!( !texte.iter().any(|m| m.contains("not defined")), "`{element}`: wird als unbekannter Bezeichner abgewiesen statt \ namentlich — {texte:?}" ); } } /// Gegenprobe: ein Element mit „nicht verfügbar"-Marker kompiliert zwar, endet /// aber zur Laufzeit in einem Fehler, der es benennt — nicht in einem /// generischen Syntaxfehler. #[test] fn offene_elemente_erzeugen_keinen_syntaxfehler() { for quelle in [ "CLS", "LOCATE 5, 10", "COLOR 14, 1", r#"OPEN "d.txt" FOR OUTPUT AS #1"#, ] { let a = tb_frontend::analyze_source("probe.bas", quelle); assert!( a.diagnostics.is_empty(), "{quelle:?}: offenes Element soll übersetzen und erst zur Laufzeit \ namentlich abgewiesen werden, bekam {:?}", a.diagnostics ); } } /// Jedes Inventarelement besitzt eine Signatur — auch die, deren /// Laufzeitverhalten noch aussteht. Sie dürfen nie als unbekannter Bezeichner /// enden (Capability `sprach-frontend`, Anforderung „Jedes dokumentierte /// Element besitzt eine Signatur"). #[test] fn bislang_signaturlose_elemente_haben_signaturen() { let quelle = r#" DIM Zahlungen#(1 TO 3) DIM D#, S$, I%, L&, C@, G! D# = FV#(0.05, 10, -100, 0, 0) D# = PV#(0.05, 10, -100, 0, 0) D# = PMT#(0.05, 10, 1000, 0, 0) D# = NPER#(0.05, -100, 1000, 0, 0) D# = IPMT#(0.05, 1, 10, 1000, 0, 0) D# = PPMT#(0.05, 1, 10, 1000, 0, 0) D# = RATE#(10, -100, 1000, 0, 0, 0.1) D# = NPV#(0.05, Zahlungen#()) D# = IRR#(Zahlungen#(), 0.1) D# = MIRR#(Zahlungen#(), 0.05, 0.06) D# = SLN#(1000, 100, 10) D# = SYD#(1000, 100, 10, 1) D# = DDB#(1000, 100, 10, 1) S$ = MKI$(1) S$ = MKL$(1) S$ = MKS$(1) S$ = MKD$(1) S$ = MKC$(1) S$ = MKSMBF$(1) S$ = MKDMBF$(1) I% = CVI("ab") L& = CVL("abcd") G! = CVS("abcd") D# = CVD("abcdefgh") C@ = CVC("abcdefgh") G! = CVSMBF("abcd") D# = CVDMBF("abcdefgh") S$ = CURDIR$ S$ = DIR$("*.txt") I% = LPOS(0) L& = STACK I% = ERDEV S$ = ERDEV$ CHDRIVE "C" STACK 2048 SetFormatCC 49 "#; let a = tb_frontend::analyze_source("signaturen.bas", quelle); let unbekannt: Vec<&tb_frontend::Diagnostic> = a .diagnostics .iter() .filter(|d| d.message.contains("not defined")) .collect(); assert!( unbekannt.is_empty(), "Elemente werden als unbekannter Bezeichner abgewiesen: {unbekannt:?}" ); assert!( a.diagnostics.is_empty(), "unerwartete Diagnosen: {:?}", a.diagnostics ); } /// Die Signatur wirkt auch dort, wo das Laufzeitverhalten noch aussteht: /// eine falsche Argumentanzahl wird gemeldet und nennt das Element. #[test] fn signatur_greift_ohne_laufzeitverhalten() { let a = tb_frontend::analyze_source("argzahl.bas", "S$ = MKL$(1, 2)"); let texte: Vec = a.diagnostics.iter().map(|d| d.message.clone()).collect(); assert!( texte.iter().any(|m| m.contains("MKL$")), "Diagnose nennt `MKL$` nicht: {texte:?}" ); } // Die Vorlagen sind feste Eingaben, keine Statusliste aus Implementierungstabellen. fn probe_source(e: &Eintrag) -> (String, tb_frontend::forms::FormCatalog) { use tb_frontend::forms::{self, ObjectClass, PropertyType}; let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); catalog.add("Probe", ObjectClass::TextBox, Some("Form1"), false); if !matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis") { let source = include_str!("../../../tests/support/inventar-quellen.tsv") .lines() .find_map(|line| { let mut columns = line.split('\t'); (columns.next() == Some(e.name.as_str()) && columns.next() == Some(e.art.as_str())) .then(|| columns.next().unwrap().replace("\\n", "\n")) }) .unwrap_or_else(|| panic!("{}: Status {}, Programmvorlage fehlt", e.name, e.status)); if e.name == "$FORM" { catalog.objects.clear(); } return (source, catalog); } let (class_name, member) = e .name .split_once(if e.art == "Ereignis" { '_' } else { '.' }) .unwrap(); let class = ObjectClass::parse(class_name).unwrap(); let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); let object = if class == ObjectClass::Form { "Form1" } else if class == ObjectClass::Screen { "SCREEN" } else { catalog.add("Probe", class, Some("Form1"), false); "Probe" }; let source = match e.art.as_str() { "Eigenschaft" => { let (_, prop) = forms::property(class, &member.to_uppercase()).unwrap(); let suffix = if matches!( member.to_uppercase().as_str(), "LIST" | "ITEMDATA" | "SELECTED" | "CONTROLPANEL" ) { "(0)" } else { "" }; if prop.ty == PropertyType::Object { format!("DIM sink AS CONTROL\nsink = {object}.{member}{suffix}") } else { format!("PRINT {object}.{member}{suffix}") } } "Methode" => { let member = member.to_uppercase(); let args = match member.as_str() { "MOVE" => " 1, 1", "DRAG" => " 0", "ADDITEM" => " \"x\"", "REMOVEITEM" => " 0", "TEXTHEIGHT" | "TEXTWIDTH" => "(\"x\")", _ => "", }; let prefix = if forms::method_return_type(class, &member).is_some() { "PRINT " } else { "" }; format!("{prefix}{object}.{member}{args}") } _ => { let args = forms::event_params(member) .unwrap() .iter() .map(|(name, ty)| { format!( "{name} AS {}", match ty { forms::EventParamType::Integer => "INTEGER", forms::EventParamType::Single => "SINGLE", forms::EventParamType::Control => "CONTROL", } ) }) .collect::>() .join(", "); let prefix = if class == ObjectClass::Form { "Form" } else { object }; format!("END\nSUB {prefix}_{member}({args})\nPRINT \"handler\"\nEND SUB") } }; (source, catalog) } fn pruefe_ausfuehrbaren_pfad(e: &Eintrag, bound: impl Fn(u16) -> bool) -> Result<(), String> { let (src, catalog) = probe_source(e); if matches!(e.name.as_str(), "$DYNAMIC" | "$STATIC") { let parsed = tb_frontend::analyze_source("FORM1", &src); let expected = e.name == "$STATIC"; if !parsed.module.body.iter().any(|s| matches!(s, tb_frontend::ast::Stmt::MetaArrays { static_arrays, .. } if *static_arrays == expected)) { return Err(format!("{}: Status {}, fehlender Metabefehl-Pfad", e.name, e.status)); } } let compiled = if e.name == "$INCLUDE" { // Einbindung gehört zum CLI-Projektlader, nicht zum Einzelmodul-Frontend. let dir = std::env::temp_dir().join(format!("tb-inventar-include-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("probe.bas"), src).unwrap(); std::fs::write(dir.join("x.bi"), "CONST Included = 73\n").unwrap(); let output = std::process::Command::new(env!("CARGO_BIN_EXE_tbc")) .args(["build", dir.join("probe.bas").to_str().unwrap()]) .output() .unwrap(); let result = if output.status.success() { tb_vm::bytecode::CompiledModule::from_tbc( &std::fs::read(dir.join("probe.tbc")).unwrap(), ) .map_err(|err| { format!( "{}: Status {}, ungültiges Kompilat: {err}", e.name, e.status ) }) } else { Err(format!( "{}: Status {}, CLI-Pfad fehlgeschlagen: {}", e.name, e.status, String::from_utf8_lossy(&output.stderr) )) }; std::fs::remove_dir_all(dir).unwrap(); Ok(result?) } else { tb_vm::compile_source_with_forms("FORM1", &src, &catalog) }; pruefe_kompilat(e, compiled, bound) } /// Feste Sollziele aus Sprache/Runtime-ABI; niemals aus dem gerade geprüften /// Codegenerator gewonnen. Deklarationen prüfen ihr Modulresultat, Forms den /// konkreten Member am tatsächlich abgesenkten Objekt. fn pruefe_sollziel(e: &Eintrag, module: &tb_vm::bytecode::CompiledModule) -> Result<(), String> { use tb_frontend::forms; use tb_vm::bytecode::Instr; let code: Vec<_> = module.procs.iter().flat_map(|p| &p.code).collect(); let member_matches = |object: u16, member: u16, method: bool| { module.objects.get(object as usize).is_some_and(|obj| { let name = if method { forms::methods(obj.class).get(member as usize).copied() } else { forms::properties(obj.class) .get(member as usize) .map(|p| p.name) }; name.is_some_and(|name| { e.name .eq_ignore_ascii_case(&format!("{}.{}", obj.class.name(), name)) }) }) }; let matches = match e.art.as_str() { "Eigenschaft" => code.iter().any(|i| match i { Instr::LoadObjectProperty(object, property, false) | Instr::LoadObjectIndexedProperty(object, property) => { member_matches(*object, *property, false) } _ => false, }), "Methode" => code.iter().any(|i| match i { Instr::ObjectMethod(object, method, _) | Instr::ObjectMethodFn(object, method, _) => { member_matches(*object, *method, true) } _ => false, }), "Ereignis" => module.event_procs.iter().any(|event| { let class = module.objects[event.object as usize].class; e.name .eq_ignore_ascii_case(&format!("{}_{}", class.name(), event.event)) && module.procs[event.proc as usize].code.iter().any(|i| { matches!( i, Instr::CallBuiltin(tb_runtime::builtins::ids::PRINT_VAL, _) ) }) }), _ => { let expected = include_str!("../../../tests/support/inventar-quellen.tsv") .lines() .find_map(|line| { let columns: Vec<_> = line.split('\t').collect(); (columns[0] == e.name && columns[1] == e.art).then_some(columns[3]) }) .unwrap(); expected.split('|').all(|contract| { if let Some(id) = contract.strip_prefix("builtin:") { let id: u16 = id.parse().unwrap(); return code .iter() .any(|i| matches!(i, Instr::CallBuiltin(actual, _) if *actual == id)); } if let Some(op) = contract.strip_prefix("code:") { return code.iter().any(|i| format!("{i:?}").starts_with(op)); } if let Some(global) = contract.strip_prefix("global:") { let (name, ty) = global.split_once(':').unwrap(); return module .global_names .iter() .zip(&module.globals_init) .any(|(actual, init)| actual == name && format!("{init:?}") == ty); } if let Some(name) = contract.strip_prefix("proc:") { return module.procs.iter().any(|p| p.name == name); } match contract { "base:1" => module.option_base == 1, "data:1" => module.data.iter().any(|d| d.text == "1"), "udt:R" => module .udts .iter() .any(|u| u.name == "R" && u.fields == [tb_runtime::value::TypeInit::Int]), "comment" => code .iter() .all(|i| matches!(i, Instr::Source(..) | Instr::Stmt(_) | Instr::End)), _ => panic!("{}: unbekannter Sollvertrag {contract}", e.name), } }) } }; if matches { Ok(()) } else { Err(format!( "{}: Status {}, falsches oder fehlendes Laufzeitziel: {code:?}", e.name, e.status )) } } fn pruefe_kompilat( e: &Eintrag, compiled: Result>, bound: impl Fn(u16) -> bool, ) -> Result<(), String> { use tb_vm::bytecode::Instr; let actual = match compiled { Err(d) if d.iter().any(|d| d.message.contains("Feature unavailable")) => { let name = e .name .to_uppercase() .replace(" (EREIGNIS)", "") .replace(" (GRAFIK)", ""); let name = name .strip_prefix("ON ") .or_else(|| name.strip_prefix("OPEN ")) .unwrap_or(&name); if !d.iter().any(|d| { d.message.contains("Feature unavailable") && d.message.to_uppercase().contains(name) }) { return Err(format!( "{}: Status {}, falsche Non-Feature-Diagnose: {d:?}", e.name, e.status )); } NON_FEATURE } Err(d) => { return Err(format!( "{}: Status {}, kein erreichbarer HIR-Pfad: {d:?}", e.name, e.status )) } Ok(module) => { if module .procs .iter() .flat_map(|p| &p.code) .any(|i| matches!(i, Instr::Unsupported(_))) { OFFEN } else { for i in module.procs.iter().flat_map(|p| &p.code) { if let Instr::CallBuiltin(id, _) = i { if !bound(*id) { return Err(format!( "{}: Status {}, fehlende Runtime-Bindung {id}", e.name, e.status )); } } } module.validate().map_err(|d| { format!( "{}: Status {}, ungültiges Laufzeitziel: {d:?}", e.name, e.status ) })?; pruefe_sollziel(e, &module)?; IMPLEMENTIERT } } }; if e.status == actual { Ok(()) } else { Err(format!( "{}: Status {}, vorgefunden {actual}", e.name, e.status )) } } #[test] fn jeder_inventareintrag_erreicht_hir_und_laufzeitziel() { let errors: Vec<_> = inventar() .iter() .filter_map(|e| { pruefe_ausfuehrbaren_pfad(e, |id| { tb_runtime::builtins::builtin_table() .get(id as usize) .is_some() }) .err() }) .collect(); assert!( errors.is_empty(), "{} Abweichungen:\n{}", errors.len(), errors.join("\n") ); } #[test] fn falscher_status_und_fehlende_runtimebindung_werden_namentlich_erkannt() { let inv = inventar(); for (name, status) in [ ("LOCATE", OFFEN), ("LOCATE", NON_FEATURE), ("PEEK", IMPLEMENTIERT), ] { let mut e = inv.iter().find(|e| e.name == name).unwrap().clone(); e.status = status.into(); let error = pruefe_ausfuehrbaren_pfad(&e, |_| true).unwrap_err(); assert!(error.contains(name) && error.contains(status), "{error}"); } let e = inv.iter().find(|e| e.name == "TIMEZONEKNOWN").unwrap(); let error = pruefe_ausfuehrbaren_pfad(e, |_| false).unwrap_err(); assert!( error.contains("TIMEZONEKNOWN") && error.contains(IMPLEMENTIERT) && error.contains("Runtime-Bindung"), "{error}" ); } #[test] fn implementiert_mit_unsupported_marker_faellt_durch() { let inv = inventar(); let e = inv.iter().find(|e| e.name == "LOCATE").unwrap(); let mut module = tb_vm::compile_source("TEST", "LOCATE 1,1").unwrap(); module.procs[0] .code .push(tb_vm::bytecode::Instr::Unsupported(0)); let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err(); assert!( error.contains("LOCATE") && error.contains(IMPLEMENTIERT) && error.contains(OFFEN), "{error}" ); } #[test] fn falsche_gueltige_builtinbindung_und_entfernte_absenkung_fallen_durch() { use tb_vm::bytecode::Instr; for e in inventar().iter().filter(|e| e.status == IMPLEMENTIERT) { let (mut src, catalog) = probe_source(e); if e.name == "$INCLUDE" { src = "PRINT 73#".into(); } let expected = include_str!("../../../tests/support/inventar-quellen.tsv") .lines() .find_map(|line| { let columns: Vec<_> = line.split('\t').collect(); (columns[0] == e.name && columns[1] == e.art).then_some(columns[3]) }); if let Some(id) = expected.and_then(|s| s.strip_prefix("builtin:")) { let id: u16 = id.split('|').next().unwrap().parse().unwrap(); let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap(); let mut changed = false; for i in module.procs.iter_mut().flat_map(|p| &mut p.code) { if let Instr::CallBuiltin(actual, _) = i { if *actual == id { *actual = (id + 1) % tb_runtime::builtins::ids::COUNT; changed = true; } } } assert!(changed, "{}: Gegenprobe traf kein Ziel", e.name); let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err(); assert!( error.contains(&e.name) && error.contains(IMPLEMENTIERT), "{error}" ); } if expected.is_some_and(|s| s.starts_with("builtin:") || s.starts_with("code:")) || matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis") { let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap(); for proc in &mut module.procs { proc.code.clear(); } let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err(); assert!( error.contains(&e.name) && error.contains(IMPLEMENTIERT), "{error}" ); } } } #[test] fn forms_absenkung_muss_den_richtigen_member_erreichen() { use tb_vm::bytecode::Instr; for name in ["TextBox.Text", "Form.Show"] { let e = inventar() .into_iter() .find(|e| e.name.eq_ignore_ascii_case(name)) .unwrap(); let (src, catalog) = probe_source(&e); let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap(); let mut changed = false; for i in module.procs.iter_mut().flat_map(|p| &mut p.code) { match i { Instr::LoadObjectProperty(object, property, _) => { *property = tb_frontend::forms::property(module.objects[*object as usize].class, "TAG") .unwrap() .0; changed = true; } Instr::ObjectMethod(object, method, _) => { *method = tb_frontend::forms::methods(module.objects[*object as usize].class) .iter() .position(|m| *m == "HIDE") .unwrap() as u16; changed = true; } _ => {} } } assert!(changed); let error = pruefe_kompilat(&e, Ok(module), |_| true).unwrap_err(); assert!( error.contains(&e.name) && error.contains(IMPLEMENTIERT), "{error}" ); } } #[test] fn gemeinsam_dispatchte_konverter_brauchen_den_richtigen_typselektor() { use tb_vm::bytecode::Instr; for name in ["MKI$", "CVD"] { let e = inventar().into_iter().find(|e| e.name == name).unwrap(); let (src, catalog) = probe_source(&e); let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap(); let mut changed = false; for i in module.procs.iter_mut().flat_map(|p| &mut p.code) { if let Instr::PushLng(kind) = i { *kind = 2; changed = true; } } assert!(changed); assert!(pruefe_kompilat(&e, Ok(module), |_| true) .unwrap_err() .contains(name)); } }