defmodule Aprsme.EncodingTest do use ExUnit.Case alias Aprsme.Encoding describe "sanitize_string/1 fast-path for clean strings" do test "returns clean ASCII string unchanged" do input = "VALID1>APRS,WIDE1-1:Test packet" assert Encoding.sanitize_string(input) == input end test "returns clean ASCII string with allowed whitespace unchanged" do input = "Hello\tWorld\nNew line\r\nCRLF" result = Encoding.sanitize_string(input) assert result == input end test "still cleans strings with control characters" do # String with null byte (control character that should be removed) input = "Hello\x00World" result = Encoding.sanitize_string(input) assert result == "HelloWorld" end test "still cleans strings with DEL character" do input = "Hello\x7FWorld" result = Encoding.sanitize_string(input) assert result == "HelloWorld" end test "handles empty string" do assert Encoding.sanitize_string("") == "" end test "handles non-binary input" do assert Encoding.sanitize_string(nil) == "" assert Encoding.sanitize_string(123) == "" end test "handles valid UTF-8 multi-byte characters" do input = "Café résumé" result = Encoding.sanitize_string(input) assert String.valid?(result) assert String.contains?(result, "Caf") end test "handles latin1 encoded binary" do # Latin1 bytes for accented characters (>= 160, valid Latin1 printable) invalid_binary = <<85, 78, 73, 211, 78, 32, 80, 65, 78, 65, 77, 69, 209, 65>> result = Encoding.sanitize_string(invalid_binary) assert String.valid?(result) # 211 = Ó, 209 = Ñ — both above 159, so they convert to real characters assert result == "UNIÓN PANAMEÑA" end end describe "sanitize_string/1 C1 control range (128-159)" do test "latin1 bytes 128-159 become replacement characters instead of being silently deleted" do input = <<72, 101, 108, 108, 111, 130>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert String.contains?(result, "\uFFFD") end test "latin1 byte 128 becomes replacement character" do input = <<65, 128, 66>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert result == "A\uFFFDB" end test "latin1 byte 159 becomes replacement character" do input = <<65, 159, 66>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert result == "A\uFFFDB" end test "latin1 bytes above 159 still convert to proper characters" do input = <<67, 97, 102, 233>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert result == "Café" end end describe "encoding_info/1" do test "returns the byte_count and char_count for valid UTF-8" do assert %{valid_utf8: true, byte_count: 5, char_count: 5} = Encoding.encoding_info("Hello") end test "returns the invalid_at offset for invalid UTF-8" do info = Encoding.encoding_info(<<65, 0xFF, 66>>) assert info.valid_utf8 == false assert info.byte_count == 3 assert info.char_count == nil assert is_integer(info.invalid_at) end test "returns the non-binary fallback shape for non-binary input" do info = Encoding.encoding_info(:not_a_binary) assert info == %{valid_utf8: false, byte_count: 0, char_count: nil, invalid_at: nil} info2 = Encoding.encoding_info(123) assert info2 == %{valid_utf8: false, byte_count: 0, char_count: nil, invalid_at: nil} end end describe "valid_codepoint? branches via the slow grapheme path" do test "tab byte (9) survives sanitization in mixed-content strings" do # 9 (tab) + 130 (C1 control) forces the slow path; tab survives. input = <<72, 9, 130, 73>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert String.contains?(result, "\t") end test "newline byte (10) survives sanitization in mixed-content strings" do input = <<72, 10, 130, 73>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert String.contains?(result, "\n") end test "carriage return byte (13) survives sanitization in mixed-content strings" do input = <<72, 13, 130, 73>> result = Encoding.sanitize_string(input) assert String.valid?(result) assert String.contains?(result, "\r") end test "other C0 control bytes are stripped in slow path" do # Byte 1 is a C0 control + byte 130 to trigger slow path. input = <<72, 1, 130, 73>> result = Encoding.sanitize_string(input) assert String.valid?(result) refute String.contains?(result, <<1>>) end end describe "to_float_safe/1" do test "parses valid numeric string" do assert Encoding.to_float_safe("3.14") == {:ok, 3.14} assert Encoding.to_float_safe("42") == {:ok, 42.0} end test "trims whitespace" do assert Encoding.to_float_safe(" 3.14 ") == {:ok, 3.14} end test "truncates strings longer than 30 characters" do # "1.0" padded with trailing zeros — valid after truncation long = "1." <> String.duplicate("0", 40) assert Encoding.to_float_safe(long) == {:ok, 1.0} end test "returns nil for out-of-range float" do assert is_nil(Encoding.to_float_safe("9.1e15")) assert is_nil(Encoding.to_float_safe("-9.1e15")) end test "returns value for in-range float" do assert {:ok, f} = Encoding.to_float_safe("1000000.0") assert f == 1_000_000.0 end test "returns nil for invalid string" do assert is_nil(Encoding.to_float_safe("abc")) assert is_nil(Encoding.to_float_safe("")) end test "returns nil for non-binary input" do assert is_nil(Encoding.to_float_safe(nil)) assert is_nil(Encoding.to_float_safe(123)) assert is_nil(Encoding.to_float_safe(:atom)) end test "handles scientific notation" do assert {:ok, f} = Encoding.to_float_safe("1.5e3") assert f == 1500.0 end test "handles zero" do assert Encoding.to_float_safe("0") == {:ok, 0.0} assert Encoding.to_float_safe("0.0") == {:ok, 0.0} end end describe "has_weather_data/4" do test "returns true when single field present" do assert Encoding.has_weather_data(72.0, nil, nil, nil) assert Encoding.has_weather_data(nil, 50, nil, nil) assert Encoding.has_weather_data(nil, nil, 5.0, nil) assert Encoding.has_weather_data(nil, nil, nil, 1013.25) end test "returns true when multiple fields present" do assert Encoding.has_weather_data(72.0, 50, 5.0, 1013.25) end test "returns false when all nil" do refute Encoding.has_weather_data(nil, nil, nil, nil) end test "treats zero as valid weather data" do assert Encoding.has_weather_data(0, nil, nil, nil) assert Encoding.has_weather_data(nil, 0, nil, nil) end test "treats negative values as valid weather data" do assert Encoding.has_weather_data(-10, nil, nil, nil) end end describe "to_hex/1" do test "returns empty string for non-binary" do assert Encoding.to_hex(nil) == "" assert Encoding.to_hex(123) == "" end test "handles boundary byte values" do assert Encoding.to_hex(<<0>>) == "00" assert Encoding.to_hex(<<255>>) == "FF" end end describe "strip_mice_telemetry/1" do test "removes Mic-E telemetry prefix from comment" do assert Encoding.strip_mice_telemetry("!w>`!Clb=6.4m/s t=-66.7C") == "Clb=6.4m/s t=-66.7C" assert Encoding.strip_mice_telemetry("!w_'!Clb=4.6m/s t=-3.8C") == "Clb=4.6m/s t=-3.8C" assert Encoding.strip_mice_telemetry("!w_P!Clb=4.8m/s t=-65.7C") == "Clb=4.8m/s t=-65.7C" assert Encoding.strip_mice_telemetry("!w;i!Clb=2.5m/s t=-66.4C") == "Clb=2.5m/s t=-66.4C" assert Encoding.strip_mice_telemetry("!w;D!Clb=2.8m/s t=-65.3C") == "Clb=2.8m/s t=-65.3C" end test "removes multiple consecutive Mic-E telemetry sequences" do assert Encoding.strip_mice_telemetry("!w>`!!abc!Normal comment") == "Normal comment" end test "does not remove exclamation marks from middle of comment" do assert Encoding.strip_mice_telemetry("Normal! comment!") == "Normal! comment!" end test "leaves normal comments unchanged" do assert Encoding.strip_mice_telemetry("Normal comment") == "Normal comment" assert Encoding.strip_mice_telemetry("Hello World") == "Hello World" assert Encoding.strip_mice_telemetry("") == "" end test "handles nil input" do assert Encoding.strip_mice_telemetry(nil) == nil end test "handles non-binary input" do assert Encoding.strip_mice_telemetry(123) == 123 end end end