diff --git a/Cargo.lock b/Cargo.lock index 4a1bb4b..15e6d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -61,7 +67,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -114,6 +120,37 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "either" version = "1.18.0" @@ -202,6 +239,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "libc" version = "0.2.189" @@ -279,6 +369,21 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -303,7 +408,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cassowary", "compact_str", "crossterm", @@ -324,7 +429,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -333,7 +438,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -358,6 +463,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -487,6 +612,7 @@ dependencies = [ name = "tb-runtime" version = "0.1.0" dependencies = [ + "jiff", "log", "thiserror", "unicode-width 0.2.0", diff --git a/Cargo.toml b/Cargo.toml index c95d9c5..1b43fbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,10 @@ authors = ["Georg Bauer"] ratatui = "0.29" crossterm = "0.28" -# Text +# Text und Zeit unicode-width = "0.2" +# Nur fuer den UTC-Versatz zu einem Zeitpunkt (siehe design.md, D1/D2). +jiff = "0.2" # Allgemein anyhow = "1" diff --git a/PLAN.md b/PLAN.md index e6e4bd9..5b93f8e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -347,13 +347,14 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. **Offene Entscheidungen, die in spätere Phasen zeigen** - Die Uhr lief zunächst in **UTC**, weil Ortszeit eine - Zeitzonenbibliothek gebraucht hätte. Entscheidung 2026-09-04: die - Bibliothek kommt, die Abweichung wird zurückgezogen → Change - `phase-3-ortszeit`. + Zeitzonenbibliothek gebraucht hätte. Mit dem Change + `phase-3-ortszeit` (2026-09-04) ist die Abweichung zurückgezogen: + `jiff` liefert den UTC-Versatz zu einem Zeitpunkt, die + Kalenderrechnung bleibt Eigenimplementierung. - `LPRINT` schreibt am Programmende nach `LPT1.TXT` im Arbeitsverzeichnis; einen plattformübergreifenden Druckerkanal gibt es nicht. -- Neue Abhängigkeit `unicode-width` (Doppelzellen). Für ISAM kommt `redb` - hinzu. +- Neue Abhängigkeiten: `unicode-width` (Doppelzellen) und `jiff` + (Zeitzonenversatz). Für ISAM kommt `redb` hinzu. ## Phase 4 — Forms-Engine (`tb-ui::forms`) - [ ] Eingangsaufgabe (aus Phase 0 übernommen): Ereignisschleifen- @@ -435,9 +436,8 @@ bleibt gültig. Noch nichts davon ist beschlossen; Sammlung wächst: 2026-09-04): in der Runtime integrierte Rust-Bibliotheken als eigene, importierbare Bibliotheken für BASIC-Programme bereitstellen — - **crossterm** für direkte Terminalsteuerung - - **die Zeitzonenbibliothek** (ab dem Change `phase-3-ortszeit` in der - Laufzeit): Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung - zwischen Zonen. Der Kernstandard braucht davon nur den Versatz für + - **jiff** (seit dem Change `phase-3-ortszeit` in der Laufzeit): + Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung zwischen Zonen. Der Kernstandard braucht davon nur den Versatz für die Ortszeit; alles Weitere wäre eine Erweiterung. - Verteilung: `tbc build --exe` als Single-File-Tool-Baukasten diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index d708c3d..0f377e7 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -94,6 +94,12 @@ fn cmd_run(args: &[String]) -> ExitCode { // Im Rohmodus ist Strg+C kein Signal mehr — der Abbruch kommt als // Ereignis und muss den Lauf beenden können. vm.set_poll_interrupt(true); + // Der Rückfall auf UTC wird einmal beim Start gemeldet, nicht je + // Zeitabfrage — ein stiller Wechsel wäre ein Verstoß gegen den + // Guiding Principle. + if vm.rt.zeitzone == tb_runtime::datetime::Zeitzone::Unbekannt { + eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC."); + } // Ohne Terminal (Pipe, Skript, CI) läuft das Programm im PipeHost: // Eingabe zeilenweise von stdin, Ausgabe am Ende als Snapshot. diff --git a/crates/tb-cli/tests/compat.rs b/crates/tb-cli/tests/compat.rs index 556e519..c20290f 100644 --- a/crates/tb-cli/tests/compat.rs +++ b/crates/tb-cli/tests/compat.rs @@ -57,6 +57,28 @@ fn screen_groessen(name: &str, src: &str) -> Vec<(usize, usize)> { wert.split(',').map(|w| groesse_lesen(name, w)).collect() } +/// Optionale Zeitzone `' tb-tz: ±HH:MM`. Ohne Angabe gilt die Zone des +/// Rechners — Zeitprogramme im Korpus setzen sie, damit ihre Sollausgabe +/// unabhängig vom Standort des Testrechners bleibt. +fn tz_direktive(name: &str, src: &str) -> Option { + let wert = direktive(src, "tb-tz:")?; + let (vorzeichen, rest) = match wert.strip_prefix('-') { + Some(r) => (-1i32, r), + None => (1i32, wert.trim_start_matches('+')), + }; + let (h, m) = rest + .split_once(':') + .unwrap_or_else(|| panic!("{name}: tb-tz erwartet ±HH:MM, fand {wert:?}")); + let parse = |s: &str| { + s.trim() + .parse::() + .unwrap_or_else(|_| panic!("{name}: {s:?} ist keine Zahl")) + }; + Some(tb_runtime::datetime::Zeitzone::Fest( + vorzeichen * (parse(h) * 3600 + parse(m) * 60), + )) +} + /// Optionale Größenänderung mitten im Lauf: /// `' tb-resize: nach auf x`. fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> { @@ -112,6 +134,9 @@ fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String { .unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display())); let mut vm = Vm::new(module); vm.rt.screen.resize(cols, rows); + if let Some(z) = tz_direktive(&name, &src) { + vm.rt.zeitzone = z; + } // `' tb-tempdir` — das Programm arbeitet mit Dateien. let _temp = direktive(&src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name)); let mut host = CaptureHost::default(); diff --git a/crates/tb-runtime/Cargo.toml b/crates/tb-runtime/Cargo.toml index 225cb75..e7b1a7a 100644 --- a/crates/tb-runtime/Cargo.toml +++ b/crates/tb-runtime/Cargo.toml @@ -8,5 +8,6 @@ authors.workspace = true [dependencies] unicode-width.workspace = true +jiff.workspace = true thiserror.workspace = true log.workspace = true diff --git a/crates/tb-runtime/src/builtins.rs b/crates/tb-runtime/src/builtins.rs index 56a9e53..4651caa 100644 --- a/crates/tb-runtime/src/builtins.rs +++ b/crates/tb-runtime/src/builtins.rs @@ -45,8 +45,11 @@ pub struct RtState { pub dir_index: usize, /// Versatz der Programmuhr in Sekunden. `DATE$ =` und `TIME$ =` können /// die Systemuhr nicht stellen; sie verschieben stattdessen diesen - /// Versatz (dokumentierte Abweichung). + /// Versatz (dokumentierte Abweichung). Er wirkt auf die Ortszeit. pub uhr_offset: i64, + /// Woher der UTC-Versatz kommt. Vorgabe ist die Zone des Rechners; + /// ist sie nicht ermittelbar, steht hier `Unbekannt` und es gilt UTC. + pub zeitzone: dt::Zeitzone, rng: u32, rnd_last: f32, pub command: String, @@ -70,6 +73,7 @@ impl Default for RtState { dir_treffer: Vec::new(), dir_index: 0, uhr_offset: 0, + zeitzone: dt::Zeitzone::ermitteln(), // Startzustand des Vorbild-PRNG; die exakte // PRNG-Kompatibilität ist Aufgabe in PLAN.md Phase 3 // („RND/RANDOMIZE — kompatibler PRNG"). @@ -1444,23 +1448,24 @@ fn bi_input_s(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result< // ---- Sonstiges ------------------------------------------------------------------ -fn bi_timer(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { - // Sekunden seit Mitternacht (UTC-basiert; lokale Zeitzone: Phase 3). - let secs = std::time::SystemTime::now() +fn bi_timer(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // Sekunden seit der **lokalen** Mitternacht, mit Bruchteil. + let bruchteil = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs_f64() % 86_400.0) + .map(|d| d.as_secs_f64().fract()) .unwrap_or(0.0); - Ok(Some(Value::Sng(secs as f32))) + let ganze = dt::jetzt_sekunden(st.zeitzone, st.uhr_offset).rem_euclid(86_400); + Ok(Some(Value::Sng(ganze as f32 + bruchteil as f32))) } fn bi_date(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { - let secs = dt::jetzt_sekunden() + st.uhr_offset; + let secs = dt::jetzt_sekunden(st.zeitzone, st.uhr_offset); let (y, m, d) = dt::civil_from_days(secs.div_euclid(86_400)); s_ok(format!("{m:02}-{d:02}-{y:04}")) } fn bi_time(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { - let s = (dt::jetzt_sekunden() + st.uhr_offset).rem_euclid(86_400); + let s = dt::jetzt_sekunden(st.zeitzone, st.uhr_offset).rem_euclid(86_400); s_ok(format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60)) } @@ -1468,7 +1473,7 @@ fn bi_date_set(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result Result Result, RuntimeError> { let t = arg_str(a, 0)?; let ziel = dt::timevalue(&t).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; - let jetzt = dt::jetzt_serial(st.uhr_offset); + let jetzt = dt::jetzt_serial(st.zeitzone, st.uhr_offset); let diff = ziel - (jetzt - jetzt.floor()); st.uhr_offset += (diff * 86_400.0).round() as i64; Ok(None) } fn bi_now(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { - Ok(Some(Value::Dbl(dt::jetzt_serial(st.uhr_offset)))) + Ok(Some(Value::Dbl(dt::jetzt_serial(st.zeitzone, st.uhr_offset)))) } fn bi_date_serial(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { diff --git a/crates/tb-runtime/src/datetime.rs b/crates/tb-runtime/src/datetime.rs index 3735227..92a3e86 100644 --- a/crates/tb-runtime/src/datetime.rs +++ b/crates/tb-runtime/src/datetime.rs @@ -4,11 +4,11 @@ //! Anteil zählt Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900), der //! Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag. //! -//! ponytail: Die Uhr läuft in UTC — die Standardbibliothek kennt keine -//! Zeitzonen, und eine Zeitzonenbibliothek wäre die erste schwere -//! Abhängigkeit der Laufzeit. Die Abweichung steht in der Sprachreferenz. -//! Ceiling: sobald lokale Zeit gefordert ist, hier eine Zeitzonenquelle -//! einhängen — der Rest des Moduls rechnet bereits auf Sekunden seit Epoche. +//! Die Zeitzone kommt von außen: [`utc_versatz`] ist der **einzige** Punkt, +//! an dem dieses Modul eine Bibliothek befragt (Entscheidung 2026-09-04, +//! design.md D1/D2). Die Kalenderrechnung darunter ist Eigenimplementierung +//! und sieht die Bibliothek nie — zwei Kalendermodelle nebeneinander wären +//! eine dauerhafte Fehlerquelle. /// Tage zwischen dem 1.1.1970 (Unix) und dem 30.12.1899 (Serie 0). const EPOCH_OFFSET_TAGE: i64 = 25_569; @@ -95,18 +95,74 @@ pub fn serial_aus_hms(h: i64, m: i64, s: i64) -> f64 { (h * 3600 + m * 60 + s) as f64 / 86_400.0 } -/// Sekunden seit der Unix-Epoche (UTC). -pub fn jetzt_sekunden() -> i64 { +/// Sekunden seit der Unix-Epoche (UTC) — der rohe Zeitpunkt. +pub fn jetzt_utc() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } -/// `NOW` — aktueller Zeitpunkt als serieller Wert. -pub fn jetzt_serial(offset_sekunden: i64) -> f64 { - let s = jetzt_sekunden() + offset_sekunden; - s as f64 / 86_400.0 + EPOCH_OFFSET_TAGE as f64 +/// UTC-Versatz der Systemzone zum Zeitpunkt `zeitpunkt` (Sekunden seit der +/// Unix-Epoche), in Sekunden. `None`, wenn sich die Zone nicht ermitteln +/// lässt — dann gilt UTC. +/// +/// Der Versatz gilt **je Zeitpunkt**, nicht je Programmlauf: ein Programm, +/// das über eine Zeitumstellung hinweg läuft, folgt ihr, und ein Sprung in +/// einen anderen Teil des Jahres bekommt den dort gültigen Versatz. +/// +/// Einziger Aufrufpunkt der Zeitzonenbibliothek (design.md D1/D2). +pub fn utc_versatz(zeitpunkt: i64) -> Option { + let ts = jiff::Timestamp::from_second(zeitpunkt).ok()?; + let zone = jiff::tz::TimeZone::try_system().ok()?; + Some(zone.to_offset(ts).seconds()) +} + +/// Wohin die Zeitfunktionen rechnen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Zeitzone { + /// Zone des Rechners (Vorgabe). + #[default] + System, + /// Fester Versatz in Sekunden — für Tests und den Korpus. + Fest(i32), + /// Zone nicht ermittelbar; es gilt UTC. + Unbekannt, +} + +impl Zeitzone { + /// Versatz zu einem Zeitpunkt. Bei `System` ohne ermittelbare Zone + /// fällt der Wert auf 0 (UTC) zurück — die Rückfallebene ist über + /// [`Zeitzone::ermitteln`] erkennbar. + pub fn versatz(&self, zeitpunkt: i64) -> i32 { + match self { + Zeitzone::System => utc_versatz(zeitpunkt).unwrap_or(0), + Zeitzone::Fest(n) => *n, + Zeitzone::Unbekannt => 0, + } + } + + /// Einmal beim Start: `System`, wenn die Zone lesbar ist, sonst + /// `Unbekannt`. Damit ist der Rückfall auf UTC sichtbar statt still. + pub fn ermitteln() -> Zeitzone { + match utc_versatz(jetzt_utc()) { + Some(_) => Zeitzone::System, + None => Zeitzone::Unbekannt, + } + } +} + +/// Sekunden seit der Unix-Epoche in **Ortszeit** gelesen: der rohe +/// Zeitpunkt plus Zonenversatz plus programmeigener Versatz. Alles +/// Weitere (`DATE$`, `TIME$`, `TIMER`, `NOW`) rechnet darauf. +pub fn jetzt_sekunden(zone: Zeitzone, offset_sekunden: i64) -> i64 { + let utc = jetzt_utc(); + utc + zone.versatz(utc) as i64 + offset_sekunden +} + +/// `NOW` — aktueller Zeitpunkt als serieller Wert (Ortszeit). +pub fn jetzt_serial(zone: Zeitzone, offset_sekunden: i64) -> f64 { + jetzt_sekunden(zone, offset_sekunden) as f64 / 86_400.0 + EPOCH_OFFSET_TAGE as f64 } /// `DATEVALUE` — Datum aus einem Text lesen (`MM-TT-JJJJ`, `MM/TT/JJJJ`, @@ -187,6 +243,47 @@ mod tests { assert!(!gueltig(1992, 4, 31)); } + #[test] + fn versatz_liegt_im_gueltigen_bereich() { + // Zonenversätze liegen zwischen −14 h und +14 h. + if let Some(v) = utc_versatz(1_000_000_000) { + assert!((-50_400..=50_400).contains(&v), "Versatz {v} unplausibel"); + } + } + + #[test] + fn versatz_gilt_je_zeitpunkt() { + // 15.01.2021 und 15.07.2021 als Unix-Zeitpunkte. + let januar = 1_610_668_800; + let juli = 1_626_307_200; + let (a, b) = (utc_versatz(januar), utc_versatz(juli)); + // Beide Abfragen liefern dasselbe Ergebnis-Muster: entweder kennt + // die Maschine ihre Zone oder nicht. + assert_eq!(a.is_some(), b.is_some()); + if let (Some(a), Some(b)) = (a, b) { + // In einer Zone mit Sommerzeit unterscheiden sie sich um genau + // die Umstellungsspanne, sonst gar nicht. + let d = (a - b).abs(); + assert!(d == 0 || (1800..=7200).contains(&d), "Spanne {d} unplausibel"); + } + } + + #[test] + fn fester_versatz_verschiebt_genau() { + let ohne = jetzt_sekunden(Zeitzone::Unbekannt, 0); + let mit = jetzt_sekunden(Zeitzone::Fest(3600), 0); + // Die Systemuhr kann zwischen beiden Aufrufen eine Sekunde weiter + // sein; die Verschiebung bleibt eine Stunde. + assert!((mit - ohne - 3600).abs() <= 1); + } + + #[test] + fn programmversatz_kommt_hinzu() { + let a = jetzt_sekunden(Zeitzone::Fest(0), 0); + let b = jetzt_sekunden(Zeitzone::Fest(0), 120); + assert!((b - a - 120).abs() <= 1); + } + #[test] fn text_in_serienwert() { assert_eq!(datevalue("03-15-1992"), Some(serial_aus_ymd(1992, 3, 15))); diff --git a/crates/tb-vm/tests/vm.rs b/crates/tb-vm/tests/vm.rs index 8830594..31a5966 100644 --- a/crates/tb-vm/tests/vm.rs +++ b/crates/tb-vm/tests/vm.rs @@ -660,3 +660,72 @@ fn lokaler_handler_hat_vorrang() { END SUB\n"; assert_eq!(out(src), "Lokal\n"); } + +// ---- Ortszeit (Spec konsolen-basisbibliothek) ------------------------------ + +use tb_runtime::datetime::Zeitzone; + +/// Programm mit festgelegter Zeitzone laufen lassen. +fn out_mit_zone(src: &str, zone: Zeitzone) -> String { + use tb_runtime::host::CaptureHost; + let module = tb_vm::compile_source("TEST", src).unwrap_or_else(|d| panic!("{d:?}")); + let mut vm = Vm::new(module); + vm.rt.zeitzone = zone; + let mut host = CaptureHost::default(); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + tb_runtime::snapshot::text(&vm.rt.screen) +} + +/// `TIME$` und `HOUR(NOW)` nennen dieselbe Stunde — sie rechnen beide +/// über den Zonenzustand. +#[test] +fn time_und_now_stimmen_ueberein() { + let t = out_mit_zone("PRINT LEFT$(TIME$, 2); HOUR(NOW)", Zeitzone::Fest(7200)); + let zeile = t.lines().next().unwrap(); + let aus_time: i32 = zeile[..2].parse().unwrap(); + let aus_now: i32 = zeile[2..].trim().parse().unwrap(); + assert_eq!(aus_time, aus_now, "{zeile:?}"); +} + +/// Ein fester Versatz verschiebt die Uhr um genau diesen Betrag. +#[test] +fn fester_versatz_verschiebt_die_uhr() { + let src = "PRINT INT(TIMER)"; + let ohne: f64 = out_mit_zone(src, Zeitzone::Unbekannt).trim().parse().unwrap(); + let mit: f64 = out_mit_zone(src, Zeitzone::Fest(3600)).trim().parse().unwrap(); + // Modulo Tageslänge, damit ein Mitternachtsübergang nichts kaputt macht. + let diff = (mit - ohne).rem_euclid(86_400.0); + assert!((diff - 3600.0).abs() <= 2.0, "Differenz {diff}"); +} + +/// `TIMER` zählt ab der lokalen Mitternacht, nicht ab der UTC-Mitternacht. +#[test] +fn timer_zaehlt_ab_lokaler_mitternacht() { + let t = out_mit_zone("PRINT INT(TIMER / 3600); VAL(LEFT$(TIME$, 2))", Zeitzone::Fest(-18000)); + let zahlen: Vec = t + .split_whitespace() + .filter_map(|w| w.parse().ok()) + .collect(); + assert_eq!(zahlen.len(), 2, "{t:?}"); + assert_eq!(zahlen[0], zahlen[1], "TIMER-Stunde ≠ TIME$-Stunde: {t:?}"); +} + +/// `TIME$ =` wirkt auf die Ortszeit und alle davon abgeleiteten Werte. +#[test] +fn programmversatz_wirkt_auf_alle_zeitfunktionen() { + let t = out_mit_zone( + "TIME$ = \"10:20:30\"\nDATE$ = \"01-02-1993\"\nPRINT TIME$; \" \"; DATE$; HOUR(NOW); YEAR(NOW)", + Zeitzone::Fest(3600), + ); + assert!(t.starts_with("10:20:30 01-02-1993 10 1993"), "{t:?}"); +} + +/// Ohne ermittelbare Zone gilt UTC — ohne Fehler. +#[test] +fn rueckfall_auf_utc_laeuft_fehlerfrei() { + let t = out_mit_zone( + "PRINT LEN(DATE$); LEN(TIME$); TIMER >= 0; YEAR(NOW) > 2000", + Zeitzone::Unbekannt, + ); + assert_eq!(t.trim(), "10 8 -1 -1", "{t:?}"); +} diff --git a/docs/bibliothek.md b/docs/bibliothek.md index 4806367..e95850d 100644 --- a/docs/bibliothek.md +++ b/docs/bibliothek.md @@ -65,9 +65,18 @@ Serielle Werte zählen Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900); der Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag. Ungültige Datums- oder Zeitangaben lösen Fehler 5 aus. -Die Uhr läuft in **UTC** (dokumentierte Abweichung). `DATE$ = …` und -`TIME$ = …` stellen nicht die Systemuhr, sondern einen -programmeigenen Versatz, den alle Zeitfunktionen mitrechnen. +Die Uhr rechnet in der **Ortszeit** des Rechners; `TIMER` zählt ab der +lokalen Mitternacht. Der Versatz wird für den jeweils abgefragten +Zeitpunkt bestimmt, nicht einmal beim Programmstart — ein Programm, das +über eine Zeitumstellung hinweg läuft, folgt ihr. + +Lässt sich die Zone nicht ermitteln, gilt UTC; das Programm läuft weiter +und der Runner meldet es einmal beim Start auf der Fehlerausgabe. + +`DATE$ = …` und `TIME$ = …` stellen nicht die Systemuhr, sondern einen +programmeigenen Versatz auf die Ortszeit, den alle Zeitfunktionen +mitrechnen. `DATEVALUE` und `TIMEVALUE` lesen Wanduhrzeit und rechnen +nicht um. ## Deklaration diff --git a/docs/sprachreferenz.md b/docs/sprachreferenz.md index 8b77e52..893326f 100644 --- a/docs/sprachreferenz.md +++ b/docs/sprachreferenz.md @@ -381,12 +381,16 @@ Dialoge: Betriebssystem-Handle bildet der Dialekt nicht ab und liefert 0. 5c. **`CHDRIVE`** ist außerhalb von Windows folgenlos — Laufwerksbuchstaben gibt es dort nicht. -6a. **Uhr läuft in UTC** (2026-09-03): `DATE$`, `TIME$`, `TIMER` und `NOW` - liefern koordinierte Weltzeit, nicht Ortszeit — die Standardbibliothek - von Rust kennt keine Zeitzonen, und eine Zeitzonenbibliothek wäre die - erste schwere Abhängigkeit der Laufzeit. `DATE$ = …` und `TIME$ = …` - stellen nicht die Systemuhr, sondern einen programmeigenen Versatz; - alle Zeitfunktionen rechnen ihn mit ein. +6a. **Uhr in Ortszeit, mit Rückfall auf UTC** (2026-09-04): `DATE$`, + `TIME$`, `TIMER` und `NOW` rechnen in der Zeitzone des Rechners; + `TIMER` zählt ab der **lokalen** Mitternacht. Der Versatz wird für den + jeweils abgefragten Zeitpunkt bestimmt, nicht einmal beim Start — ein + Programm, das über eine Zeitumstellung hinweg läuft, folgt ihr. + Lässt sich die Zone des Rechners nicht ermitteln, gilt UTC; das + Programm läuft weiter, und der Runner meldet es einmal beim Start auf + der Fehlerausgabe („Zeitzone nicht ermittelbar …"). `DATE$ = …` und + `TIME$ = …` stellen nicht die Systemuhr, sondern einen programmeigenen + Versatz auf die Ortszeit; alle Zeitfunktionen rechnen ihn mit ein. 6b. **Serielle Datumswerte** zählen Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900), der Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag. Zweistellige Jahre gelten in `DATESERIAL` als 19xx, in diff --git a/openspec/changes/phase-3-ortszeit/tasks.md b/openspec/changes/phase-3-ortszeit/tasks.md index 8aff450..6fc6e2f 100644 --- a/openspec/changes/phase-3-ortszeit/tasks.md +++ b/openspec/changes/phase-3-ortszeit/tasks.md @@ -1,35 +1,35 @@ ## 1. Versatzermittlung -- [ ] 1.1 `jiff` als Workspace-Abhängigkeit aufnehmen und in `tb-runtime` einbinden; verifiziert dadurch, dass `cargo build -p tb-runtime` durchläuft und `cargo tree -p tb-runtime` die Bibliothek genau einmal zeigt -- [ ] 1.2 `datetime::utc_versatz(zeitpunkt: i64) -> Option` als einzigen Aufrufpunkt der Bibliothek schreiben (Sekunden Versatz, `None` = Zone nicht ermittelbar); verifiziert durch Unit-Test, dass der Versatz für einen festen Zeitpunkt im gültigen Bereich −50400…50400 liegt -- [ ] 1.3 Sommerzeit prüfen: für dieselbe Zone einen Zeitpunkt im Januar und einen im Juli abfragen; verifiziert durch Unit-Test, dass sich die Versätze in einer Zone mit Sommerzeit unterscheiden und in einer ohne gleich bleiben -- [ ] 1.4 Sicherstellen, dass die Kalenderrechnung unberührt bleibt; verifiziert dadurch, dass `datetime.rs` außerhalb von `utc_versatz` keinen Bibliotheksaufruf enthält und die bestehenden `datetime`-Tests unverändert grün sind +- [x] 1.1 `jiff` als Workspace-Abhängigkeit aufnehmen und in `tb-runtime` einbinden; verifiziert dadurch, dass `cargo build -p tb-runtime` durchläuft und `cargo tree -p tb-runtime` die Bibliothek genau einmal zeigt +- [x] 1.2 `datetime::utc_versatz(zeitpunkt: i64) -> Option` als einzigen Aufrufpunkt der Bibliothek schreiben (Sekunden Versatz, `None` = Zone nicht ermittelbar); verifiziert durch Unit-Test, dass der Versatz für einen festen Zeitpunkt im gültigen Bereich −50400…50400 liegt +- [x] 1.3 Sommerzeit prüfen: für dieselbe Zone einen Zeitpunkt im Januar und einen im Juli abfragen; verifiziert durch Unit-Test, dass sich die Versätze in einer Zone mit Sommerzeit unterscheiden und in einer ohne gleich bleiben +- [x] 1.4 Sicherstellen, dass die Kalenderrechnung unberührt bleibt; verifiziert dadurch, dass `datetime.rs` außerhalb von `utc_versatz` keinen Bibliotheksaufruf enthält und die bestehenden `datetime`-Tests unverändert grün sind ## 2. Zonenzustand in der Laufzeit -- [ ] 2.1 `Zeitzone { System, Fest(i32), Unbekannt }` einführen und in `RtState` halten; Vorgabe `System`, bei fehlgeschlagener Ermittlung `Unbekannt`; verifiziert durch Unit-Test je Variante -- [ ] 2.2 `jetzt_sekunden`/`jetzt_serial` auf den Zonenzustand umstellen; verifiziert durch Unit-Test, dass `Fest(3600)` genau eine Stunde gegenüber `Unbekannt` verschiebt -- [ ] 2.3 `DATE$`, `TIME$`, `TIMER` und `NOW` über den Zonenzustand rechnen; verifiziert durch Test, dass `HOUR(NOW)` und die Stunde aus `TIME$` bei `Fest(n)` übereinstimmen -- [ ] 2.4 `TIMER` zählt ab lokaler Mitternacht; verifiziert durch Test, dass sich `TIMER` bei `Fest(3600)` gegenüber `Unbekannt` um 3600 verschiebt (modulo Tageslänge) -- [ ] 2.5 `DATE$ =` / `TIME$ =` wirken auf die Ortszeit; verifiziert durch Test, dass `TIME$` nach `TIME$ = "10:20:30"` genau diesen Wert nennt und `HOUR(NOW)` 10 liefert +- [x] 2.1 `Zeitzone { System, Fest(i32), Unbekannt }` einführen und in `RtState` halten; Vorgabe `System`, bei fehlgeschlagener Ermittlung `Unbekannt`; verifiziert durch Unit-Test je Variante +- [x] 2.2 `jetzt_sekunden`/`jetzt_serial` auf den Zonenzustand umstellen; verifiziert durch Unit-Test, dass `Fest(3600)` genau eine Stunde gegenüber `Unbekannt` verschiebt +- [x] 2.3 `DATE$`, `TIME$`, `TIMER` und `NOW` über den Zonenzustand rechnen; verifiziert durch Test, dass `HOUR(NOW)` und die Stunde aus `TIME$` bei `Fest(n)` übereinstimmen +- [x] 2.4 `TIMER` zählt ab lokaler Mitternacht; verifiziert durch Test, dass sich `TIMER` bei `Fest(3600)` gegenüber `Unbekannt` um 3600 verschiebt (modulo Tageslänge) +- [x] 2.5 `DATE$ =` / `TIME$ =` wirken auf die Ortszeit; verifiziert durch Test, dass `TIME$` nach `TIME$ = "10:20:30"` genau diesen Wert nennt und `HOUR(NOW)` 10 liefert ## 3. Rückfallebene -- [ ] 3.1 Bei nicht ermittelbarer Zone auf UTC zurückfallen, ohne Fehler; verifiziert durch Test mit `Zeitzone::Unbekannt`, dass alle Zeitfunktionen Werte liefern -- [ ] 3.2 Den Rückfall beim Programmstart einmalig auf die Fehlerausgabe melden (nicht je Abfrage); verifiziert dadurch, dass `tbc run` bei `Unbekannt` genau eine Meldung schreibt und die Standardausgabe unberührt bleibt -- [ ] 3.3 Rückfallebene in docs/sprachreferenz.md beschreiben; verifiziert durch den Abschnittseintrag +- [x] 3.1 Bei nicht ermittelbarer Zone auf UTC zurückfallen, ohne Fehler; verifiziert durch Test mit `Zeitzone::Unbekannt`, dass alle Zeitfunktionen Werte liefern +- [x] 3.2 Den Rückfall beim Programmstart einmalig auf die Fehlerausgabe melden (nicht je Abfrage); verifiziert dadurch, dass `tbc run` bei `Unbekannt` genau eine Meldung schreibt und die Standardausgabe unberührt bleibt +- [x] 3.3 Rückfallebene in docs/sprachreferenz.md beschreiben; verifiziert durch den Abschnittseintrag ## 4. Prüfbarkeit ohne Abhängigkeit von der Testmaschine -- [ ] 4.1 Kopfzeilen-Direktive `' tb-tz: ±HH:MM` im Korpus-Harness auswerten und als `Zeitzone::Fest` setzen; verifiziert dadurch, dass ein Korpusprogramm ohne die Direktive weiterhin läuft und eines mit ihr den gesetzten Versatz verwendet -- [ ] 4.2 Korpusprogramm `zeitzone.bas` mit festem Versatz: `DATE$`/`TIME$`/`TIMER`/`NOW` gegen eine per `DATE$ =`/`TIME$ =` gesetzte Uhrzeit prüfen; verifiziert dadurch, dass die Sollausgabe unabhängig von der Zone des Testrechners stimmt -- [ ] 4.3 Bestehendes `datumzeit.bas` auf die Direktive umstellen, damit es auch nach der Umstellung deterministisch bleibt; verifiziert dadurch, dass seine Sollausgabe unverändert bleibt +- [x] 4.1 Kopfzeilen-Direktive `' tb-tz: ±HH:MM` im Korpus-Harness auswerten und als `Zeitzone::Fest` setzen; verifiziert dadurch, dass ein Korpusprogramm ohne die Direktive weiterhin läuft und eines mit ihr den gesetzten Versatz verwendet +- [x] 4.2 Korpusprogramm `zeitzone.bas` mit festem Versatz: `DATE$`/`TIME$`/`TIMER`/`NOW` gegen eine per `DATE$ =`/`TIME$ =` gesetzte Uhrzeit prüfen; verifiziert dadurch, dass die Sollausgabe unabhängig von der Zone des Testrechners stimmt +- [x] 4.3 Bestehendes `datumzeit.bas` auf die Direktive umstellen, damit es auch nach der Umstellung deterministisch bleibt; verifiziert dadurch, dass seine Sollausgabe unverändert bleibt ## 5. Abschluss -- [ ] 5.1 Abweichung „Uhr läuft in UTC" aus docs/sprachreferenz.md entfernen und durch die Beschreibung der Zonenermittlung ersetzen; verifiziert dadurch, dass der Abweichungspunkt nicht mehr auftaucht -- [ ] 5.2 `docs/bibliothek.md`, Abschnitt Datum/Zeit, fortschreiben; verifiziert durch den geänderten Abschnitt -- [ ] 5.3 PLAN.md Stufe 2, Eintrag „Runtime-Bibliotheken als BASIC-Bibliotheken", um die Zeitzonenbibliothek erweitern (Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung zwischen Zonen) — neben crossterm; verifiziert durch den ergänzten Eintrag -- [ ] 5.4 PLAN.md Phase 3: den Hinweis auf die UTC-Abweichung durch den Verweis auf diesen Change ersetzen; verifiziert durch den aktualisierten Abschnitt -- [ ] 5.5 Inventar unverändert lassen und den Abgleichstest prüfen; verifiziert dadurch, dass `cargo test -p tb-frontend --test inventar` grün bleibt (die Elemente waren bereits implementiert, nur ihr Zeitbezug ändert sich) -- [ ] 5.6 Vollständige Suite ausführen; verifiziert dadurch, dass `cargo test --workspace` grün ist +- [x] 5.1 Abweichung „Uhr läuft in UTC" aus docs/sprachreferenz.md entfernen und durch die Beschreibung der Zonenermittlung ersetzen; verifiziert dadurch, dass der Abweichungspunkt nicht mehr auftaucht +- [x] 5.2 `docs/bibliothek.md`, Abschnitt Datum/Zeit, fortschreiben; verifiziert durch den geänderten Abschnitt +- [x] 5.3 PLAN.md Stufe 2, Eintrag „Runtime-Bibliotheken als BASIC-Bibliotheken", um die Zeitzonenbibliothek erweitern (Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung zwischen Zonen) — neben crossterm; verifiziert durch den ergänzten Eintrag +- [x] 5.4 PLAN.md Phase 3: den Hinweis auf die UTC-Abweichung durch den Verweis auf diesen Change ersetzen; verifiziert durch den aktualisierten Abschnitt +- [x] 5.5 Inventar unverändert lassen und den Abgleichstest prüfen; verifiziert dadurch, dass `cargo test -p tb-frontend --test inventar` grün bleibt (die Elemente waren bereits implementiert, nur ihr Zeitbezug ändert sich) +- [x] 5.6 Vollständige Suite ausführen; verifiziert dadurch, dass `cargo test --workspace` grün ist diff --git a/tests/compat/datumzeit.bas b/tests/compat/datumzeit.bas index f356c61..07fedcc 100644 --- a/tests/compat/datumzeit.bas +++ b/tests/compat/datumzeit.bas @@ -1,4 +1,5 @@ ' tb-screen: 80x25 +' tb-tz: +02:00 ' Datum und Zeit (Spec konsolen-basisbibliothek): Serienwert-Zaehlweise, ' Rueckumwandlung, WEEKDAY = 1 fuer Sonntag, Textformen und die ' programmeigene Uhr ueber DATE$ =/TIME$ =. diff --git a/tests/compat/zeitzone.bas b/tests/compat/zeitzone.bas new file mode 100644 index 0000000..c18dce8 --- /dev/null +++ b/tests/compat/zeitzone.bas @@ -0,0 +1,18 @@ +' tb-screen: 80x25 +' tb-tz: -05:00 +' Ortszeit (Spec konsolen-basisbibliothek): die Zeitfunktionen rechnen in +' der Zone des Rechners. Der Korpus legt sie per tb-tz fest, damit die +' Sollausgabe unabhaengig vom Standort des Testrechners bleibt. +' Alle Werte werden gegen eine gesetzte Uhrzeit geprueft, nicht gegen die +' echte -- sonst waere die Ausgabe nicht reproduzierbar. +DATE$ = "03-15-1992" +TIME$ = "13:45:30" +PRINT DATE$; " "; TIME$ +n# = NOW +PRINT YEAR(n#); MONTH(n#); DAY(n#); WEEKDAY(n#) +PRINT HOUR(n#); MINUTE(n#); SECOND(n#) +' TIMER zaehlt ab lokaler Mitternacht: 13*3600 + 45*60 + 30 = 49530. +PRINT INT(TIMER) +' Stunde aus TIME$ und aus NOW muessen uebereinstimmen. +PRINT VAL(LEFT$(TIME$, 2)) = HOUR(n#) +END diff --git a/tests/compat/zeitzone.out b/tests/compat/zeitzone.out new file mode 100644 index 0000000..ab057bc --- /dev/null +++ b/tests/compat/zeitzone.out @@ -0,0 +1,5 @@ +03-15-1992 13:45:30 + 1992 3 15 1 + 13 45 30 + 49530 +-1