1085 lines
38 KiB
Rust
1085 lines
38 KiB
Rust
//! 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<Eintrag> {
|
|
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<String> {
|
|
let mut out = BTreeSet::new();
|
|
let bytes: Vec<char> = 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<String>,
|
|
verboten: BTreeSet<String>,
|
|
nicht_verfuegbar: BTreeSet<String>,
|
|
/// Non-Features, die an ihrer Syntax statt an ihrem Namen erkannt werden.
|
|
syntaktisch: BTreeSet<String>,
|
|
}
|
|
|
|
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<String> {
|
|
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<String> = 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<String, Vec<&Eintrag>> = 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<String> = 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()
|
|
);
|
|
|
|
// 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<String> = 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<String> = 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::<Vec<_>>()
|
|
.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<tb_vm::bytecode::CompiledModule, Vec<tb_frontend::Diagnostic>>,
|
|
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));
|
|
}
|
|
}
|