use std::fs; use std::path::Path; use dd3_contracts::{ build_ha_discovery_payload, format_csv_line, html_escape, mqtt_state_json, sanitize_device_id, url_encode_component, CsvLineInput, HaDiscoverySpec, MqttState, CSV_HEADER, HA_MANUFACTURER, }; fn fixture(path: &str) -> String { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let full = root.join("../../").join(path); fs::read_to_string(full).unwrap() } #[test] fn ha_discovery_snapshot_and_manufacturer_lock() { let spec = HaDiscoverySpec { device_id: "dd3-F19C", key: "energy", name: "Energy", unit: Some("kWh"), device_class: Some("energy"), state_topic: "smartmeter/dd3-F19C/state", value_template: "{{ value_json.e_kwh }}", }; let actual = build_ha_discovery_payload(&spec); let expected = fixture("fixtures/contracts/ha_discovery/energy.json"); assert_eq!(expected, actual); assert!(actual.contains("\"manufacturer\":\"AcidBurns\"")); } #[test] fn mqtt_state_snapshot_required_keys_and_no_legacy_keys() { let state = MqttState { device_id: "dd3-F19C", ts_utc: 1_769_905_000, energy_total_kwh: 1234.5678, total_power_w: 321.6, phase_power_w: [100.4, 110.4, 110.8], battery_voltage_v: 3.876, battery_percent: 77, link_valid: true, link_rssi_dbm: -71, link_snr_db: 7.25, err_meter_read: 1, err_decode: 2, err_lora_tx: 3, err_last: 2, rx_reject_reason: 1, }; let actual = mqtt_state_json(&state); let expected = fixture("fixtures/contracts/mqtt_state/sample.json"); assert_eq!(expected, actual); for key in [ "\"id\"", "\"ts\"", "\"e_kwh\"", "\"p_w\"", "\"p1_w\"", "\"p2_w\"", "\"p3_w\"", "\"bat_v\"", "\"bat_pct\"", "\"rssi\"", "\"snr\"", "\"err_m\"", "\"err_d\"", "\"err_tx\"", "\"err_last\"", "\"rx_reject\"", "\"rx_reject_text\"", ] { assert!(actual.contains(key), "missing key {key}"); } assert!(!actual.contains("energy_total_kwh")); assert!(!actual.contains("power_w")); assert!(!actual.contains("battery_voltage")); } #[test] fn mqtt_state_optional_fields_omitted_when_unavailable() { let state = MqttState { device_id: "dd3-F19C", ts_utc: 1_769_905_000, energy_total_kwh: 10.0, total_power_w: 100.0, phase_power_w: [30.0, 30.0, 40.0], battery_voltage_v: 3.9, battery_percent: 88, link_valid: false, link_rssi_dbm: -80, link_snr_db: 3.2, err_meter_read: 0, err_decode: 0, err_lora_tx: 0, err_last: 0, rx_reject_reason: 0, }; let json = mqtt_state_json(&state); assert!(!json.contains("\"rssi\"")); assert!(!json.contains("\"snr\"")); assert!(!json.contains("\"err_m\"")); assert!(!json.contains("\"err_d\"")); assert!(!json.contains("\"err_tx\"")); assert!(json.contains("\"rx_reject_text\":\"none\"")); } #[test] fn csv_header_and_line_snapshot() { assert_eq!( "ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last", CSV_HEADER ); let line = format_csv_line(&CsvLineInput { ts_utc: 1_769_905_000, ts_hms_local: "12:34:56", p_w: 321.6, p1_w: 100.4, p2_w: 110.4, p3_w: 110.8, e_kwh: 1234.5678, bat_v: 3.876, bat_pct: 77, rssi: -71, snr: 7.25, err_m: 1, err_d: 2, err_tx: 3, err_last_text: Some("decode"), include_error_text: true, }); let snapshot = fixture("fixtures/contracts/sd_csv/sample.csv"); let expected_line = snapshot.lines().nth(1).unwrap(); assert_eq!(expected_line, line); } #[test] fn html_url_and_sanitize_table_cases() { assert_eq!("", html_escape("")); assert_eq!("plain", html_escape("plain")); assert_eq!("a&b", html_escape("a&b")); assert_eq!("<tag>", html_escape("")); assert_eq!(""hi"", html_escape("\"hi\"")); assert_eq!("it's", html_escape("it's")); assert_eq!("&<>"'", html_escape("&<>\"'")); assert_eq!("&amp;", html_escape("&")); assert_eq!("", url_encode_component("")); assert_eq!("abcABC012-_.~", url_encode_component("abcABC012-_.~")); assert_eq!("a%20b", url_encode_component("a b")); assert_eq!("%2F%5C%3F%26%23%25%22%27", url_encode_component("/\\?&#%\"'")); assert_eq!("line%0Abreak", url_encode_component("line\nbreak")); assert_eq!("%01%1F%7F", url_encode_component(&String::from_utf8(vec![1, 31, 127]).unwrap())); for accept in ["F19C", "f19c", " f19c ", "dd3-f19c", "dd3-F19C", "dd3-a0b1"] { let out = sanitize_device_id(accept).unwrap(); if accept.contains("a0b1") { assert_eq!("dd3-A0B1", out); } else { assert_eq!("dd3-F19C", out); } } for reject in [ "", "F", "FFF", "FFFFF", "dd3-12", "dd3-12345", "F1 9C", "dd3-F1\t9C", "dd3-F19C%00", "%F19C", "../F19C", "dd3-..1A", "dd3-12/3", "dd3-12\\3", "F19G", "dd3-zzzz", ] { assert!(sanitize_device_id(reject).is_err(), "unexpected accept: {reject}"); } } #[test] fn manufacturer_drift_guard() { assert_eq!("AcidBurns", HA_MANUFACTURER); let roots = [Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; for root in roots { let entries = fs::read_dir(&root).unwrap(); for entry in entries { let path = entry.unwrap().path(); if path.extension().and_then(|x| x.to_str()) != Some("rs") { continue; } let txt = fs::read_to_string(&path).unwrap(); if path.ends_with(Path::new("lib.rs")) { continue; } assert!(!txt.contains("\"AcidBurns\""), "hardcoded manufacturer in {}", path.display()); } } }