Bootstrap DD3 Rust port workspace with host-first compatibility tests
This commit is contained in:
184
crates/dd3_contracts/tests/contracts_tests.rs
Normal file
184
crates/dd3_contracts/tests/contracts_tests.rs
Normal 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!("<tag>", html_escape("<tag>"));
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user