Bootstrap DD3 Rust port workspace with host-first compatibility tests

This commit is contained in:
2026-02-21 00:59:03 +01:00
parent d3f9a2e62d
commit d0212f4e38
63 changed files with 3914 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
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!("&lt;tag&gt;", html_escape("<tag>"));
assert_eq!("&quot;hi&quot;", html_escape("\"hi\""));
assert_eq!("it&#39;s", html_escape("it's"));
assert_eq!("&amp;&lt;&gt;&quot;&#39;", html_escape("&<>\"'"));
assert_eq!("&amp;amp;", html_escape("&amp;"));
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());
}
}
}