diff --git a/tests/test_auth.py b/tests/test_auth.py index d9be297..5a91478 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -853,3 +853,278 @@ async def test_no_prompt_error_contains_interactive_login( msg = str(exc_info.value) assert msg.startswith("Interactive login is required") assert "Auth URI:" in msg + + +# --------------------------------------------------------------------------- +# TokenAuth -- callable(self._token) mutant killer +# --------------------------------------------------------------------------- + + +class TestTokenAuthCallableMutant: + """Kill mutant: callable(self._token) → callable(None).""" + + async def test_callable_token_is_awaited_not_returned_raw(self): + """If callable() were always False, we'd get the coroutine function back.""" + + async def factory() -> str: + return "awaited-value" + + auth = TokenAuth(factory) + result = await auth.get_token() + # Must be the awaited string, not the coroutine function itself + assert result == "awaited-value" + assert isinstance(result, str) + assert not callable(result) + + async def test_string_token_not_awaited(self): + """String tokens must be returned as-is (not called).""" + auth = TokenAuth("plain") + result = await auth.get_token() + assert result == "plain" + + +# --------------------------------------------------------------------------- +# MsalAuth._build_app -- direct call tests to kill constructor-arg mutants +# --------------------------------------------------------------------------- + + +class TestBuildAppDirect: + """Call _build_app() directly to kill mutants on constructor args.""" + + @patch("flameconnect.auth.msal") + def test_returns_app_and_cache(self, mock_msal, tmp_path): + """_build_app returns (app, cache) tuple.""" + cache_path = tmp_path / "token.json" + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_app = MagicMock() + mock_msal.PublicClientApplication.return_value = mock_app + + auth = MsalAuth(cache_path=cache_path) + app, cache = auth._build_app() + + assert app is mock_app + assert cache is mock_cache + + @patch("flameconnect.auth.msal") + def test_serializable_token_cache_called(self, mock_msal, tmp_path): + """msal.SerializableTokenCache() is called (not replaced with None).""" + cache_path = tmp_path / "token.json" + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + mock_msal.SerializableTokenCache.assert_called_once_with() + + @patch("flameconnect.auth.msal") + def test_cache_not_deserialized_when_file_missing(self, mock_msal, tmp_path): + """When cache file doesn't exist, deserialize is NOT called.""" + cache_path = tmp_path / "nonexistent.json" + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + mock_cache.deserialize.assert_not_called() + + @patch("flameconnect.auth.msal") + def test_cache_deserialized_with_file_text(self, mock_msal, tmp_path): + """When cache file exists, deserialize receives its text content.""" + cache_path = tmp_path / "token.json" + cache_path.write_text('{"cached": true}') + + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + mock_cache.deserialize.assert_called_once_with('{"cached": true}') + + @patch("flameconnect.auth.msal") + def test_public_client_app_receives_client_id(self, mock_msal, tmp_path): + """CLIENT_ID is the first positional arg.""" + cache_path = tmp_path / "token.json" + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + args, kwargs = mock_msal.PublicClientApplication.call_args + assert args == (CLIENT_ID,) + + @patch("flameconnect.auth.msal") + def test_public_client_app_receives_authority(self, mock_msal, tmp_path): + """authority=AUTHORITY is passed.""" + cache_path = tmp_path / "token.json" + mock_msal.SerializableTokenCache.return_value = MagicMock() + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + _, kwargs = mock_msal.PublicClientApplication.call_args + assert kwargs["authority"] is AUTHORITY + + @patch("flameconnect.auth.msal") + def test_public_client_app_validate_authority_false(self, mock_msal, tmp_path): + """validate_authority=False (not True, not missing).""" + cache_path = tmp_path / "token.json" + mock_msal.SerializableTokenCache.return_value = MagicMock() + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + _, kwargs = mock_msal.PublicClientApplication.call_args + assert kwargs["validate_authority"] is False + + @patch("flameconnect.auth.msal") + def test_public_client_app_receives_token_cache(self, mock_msal, tmp_path): + """token_cache= receives the SerializableTokenCache instance.""" + cache_path = tmp_path / "token.json" + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + _, kwargs = mock_msal.PublicClientApplication.call_args + assert kwargs["token_cache"] is mock_cache + + @patch("flameconnect.auth.msal") + def test_public_client_app_all_kwargs(self, mock_msal, tmp_path): + """All keyword args passed in a single assertion.""" + cache_path = tmp_path / "token.json" + mock_cache = MagicMock() + mock_msal.SerializableTokenCache.return_value = mock_cache + mock_msal.PublicClientApplication.return_value = MagicMock() + + auth = MsalAuth(cache_path=cache_path) + auth._build_app() + + mock_msal.PublicClientApplication.assert_called_once_with( + CLIENT_ID, + authority=AUTHORITY, + validate_authority=False, + token_cache=mock_cache, + ) + + +# --------------------------------------------------------------------------- +# MsalAuth._save_cache -- direct call tests to kill mkdir/write/log mutants +# --------------------------------------------------------------------------- + + +class TestSaveCacheDirect: + """Call _save_cache() directly to kill mutants on mkdir/write_text/log.""" + + def test_mkdir_called_with_parents_and_exist_ok(self, tmp_path): + """mkdir receives parents=True, exist_ok=True.""" + cache_path = tmp_path / "sub" / "dir" / "token.json" + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = True + mock_cache.serialize.return_value = "{}" + + auth._save_cache(mock_cache) + + # Parent dir was created (proves parents=True works) + assert cache_path.parent.exists() + assert cache_path.exists() + + def test_mkdir_exist_ok_true(self, tmp_path): + """Calling _save_cache when parent dir already exists doesn't raise.""" + cache_path = tmp_path / "existing" / "token.json" + cache_path.parent.mkdir(parents=True) + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = True + mock_cache.serialize.return_value = '{"data": 1}' + + # Should not raise (proves exist_ok=True) + auth._save_cache(mock_cache) + assert cache_path.exists() + + def test_write_text_receives_serialized_content(self, tmp_path): + """write_text receives cache.serialize() output (not None).""" + cache_path = tmp_path / "token.json" + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = True + mock_cache.serialize.return_value = '{"tokens": "abc"}' + + auth._save_cache(mock_cache) + + assert cache_path.read_text() == '{"tokens": "abc"}' + mock_cache.serialize.assert_called_once() + + def test_cache_not_saved_when_unchanged(self, tmp_path): + """When has_state_changed is False, nothing is written.""" + cache_path = tmp_path / "token.json" + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = False + + auth._save_cache(mock_cache) + + assert not cache_path.exists() + mock_cache.serialize.assert_not_called() + + def test_log_message_format(self, tmp_path, caplog): + """Log message uses correct format string and includes cache path.""" + cache_path = tmp_path / "token.json" + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = True + mock_cache.serialize.return_value = "{}" + + with caplog.at_level(logging.DEBUG): + auth._save_cache(mock_cache) + + messages = [r.message for r in caplog.records] + assert len(messages) == 1 + assert messages[0] == f"Token cache saved to {cache_path}" + + def test_log_uses_percent_format_args(self, tmp_path, caplog): + """Verify the logger record uses %-style args (not f-string).""" + cache_path = tmp_path / "token.json" + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = True + mock_cache.serialize.return_value = "{}" + + with caplog.at_level(logging.DEBUG): + auth._save_cache(mock_cache) + + record = caplog.records[0] + assert record.args == (cache_path,) + assert record.msg == "Token cache saved to %s" + + def test_no_log_when_cache_unchanged(self, tmp_path, caplog): + """No log message emitted when cache hasn't changed.""" + cache_path = tmp_path / "token.json" + auth = MsalAuth(cache_path=cache_path) + + mock_cache = MagicMock() + mock_cache.has_state_changed = False + + with caplog.at_level(logging.DEBUG): + auth._save_cache(mock_cache) + + assert len(caplog.records) == 0 diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 5b7c10e..13658fe 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1732,3 +1732,1158 @@ async def test_all_parameter_types(self, capsys): assert "[236] Temperature Unit" in out assert "[369] Sound" in out assert "[370] Log Effect" in out + + +# =================================================================== +# Mutation-killing tests: build_parser +# =================================================================== + + +class TestBuildParserMutants: + """Precise tests for build_parser to kill string/config mutations.""" + + def test_parser_description_not_none(self): + parser = build_parser() + assert parser.description is not None + + def test_parser_description_contains_expected_text(self): + parser = build_parser() + assert "Control" in parser.description + assert "Dimplex" in parser.description + assert "Faber" in parser.description + assert "Real Flame" in parser.description + assert "Flame Connect" in parser.description + assert "cloud API" in parser.description + + def test_parser_description_exact(self): + parser = build_parser() + expected = ( + "Control Dimplex, Faber, and Real Flame fireplaces" + " via the Flame Connect cloud API" + ) + assert parser.description == expected + + def test_parser_prog(self): + parser = build_parser() + assert parser.prog == "flameconnect" + + def test_verbose_flag_help(self): + parser = build_parser() + for action in parser._actions: + if "--verbose" in getattr(action, "option_strings", []): + assert action.help == "Enable debug logging" + assert "-v" in action.option_strings + break + else: + pytest.fail("--verbose action not found") + + def test_verbose_flag_is_store_true(self): + parser = build_parser() + for action in parser._actions: + if "--verbose" in getattr(action, "option_strings", []): + assert action.const is True + break + + def test_subcommand_names(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + assert set(choices.keys()) == {"list", "status", "on", "off", "set", "tui"} + + def test_list_help(self): + parser = build_parser() + sp_actions = parser._subparsers._group_actions[0] + for ca in sp_actions._choices_actions: + if ca.dest == "list": + assert ca.help == "List registered fireplaces" + break + + def test_status_help(self): + parser = build_parser() + sp_actions = parser._subparsers._group_actions[0] + for ca in sp_actions._choices_actions: + if ca.dest == "status": + assert ca.help == "Show current fireplace status" + break + + def test_on_help(self): + parser = build_parser() + sp_actions = parser._subparsers._group_actions[0] + for ca in sp_actions._choices_actions: + if ca.dest == "on": + assert ca.help == "Turn on a fireplace" + break + + def test_off_help(self): + parser = build_parser() + sp_actions = parser._subparsers._group_actions[0] + for ca in sp_actions._choices_actions: + if ca.dest == "off": + assert ca.help == "Turn off a fireplace" + break + + def test_set_help(self): + parser = build_parser() + sp_actions = parser._subparsers._group_actions[0] + for ca in sp_actions._choices_actions: + if ca.dest == "set": + assert ca.help == "Set a fireplace parameter" + break + + def test_tui_help(self): + parser = build_parser() + sp_actions = parser._subparsers._group_actions[0] + for ca in sp_actions._choices_actions: + if ca.dest == "tui": + assert ca.help == "Launch the interactive TUI" + break + + def test_status_fire_id_help(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_status = choices["status"] + for action in sp_status._actions: + if action.dest == "fire_id": + assert action.help == "Fireplace ID" + break + else: + pytest.fail("fire_id action not found in status subparser") + + def test_on_fire_id_help(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_on = choices["on"] + for action in sp_on._actions: + if action.dest == "fire_id": + assert action.help == "Fireplace ID" + break + else: + pytest.fail("fire_id action not found in on subparser") + + def test_off_fire_id_help(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_off = choices["off"] + for action in sp_off._actions: + if action.dest == "fire_id": + assert action.help == "Fireplace ID" + break + else: + pytest.fail("fire_id action not found in off subparser") + + def test_set_fire_id_help(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_set = choices["set"] + for action in sp_set._actions: + if action.dest == "fire_id": + assert action.help == "Fireplace ID" + break + else: + pytest.fail("fire_id action not found in set subparser") + + def test_set_param_help(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_set = choices["set"] + for action in sp_set._actions: + if action.dest == "param": + assert "Parameter name" in action.help + assert "mode" in action.help + assert "flame-speed" in action.help + assert "brightness" in action.help + assert "pulsating" in action.help + assert "flame-color" in action.help + assert "media-theme" in action.help + assert "heat-status" in action.help + assert "heat-mode" in action.help + assert "heat-temp" in action.help + assert "timer" in action.help + assert "temp-unit" in action.help + assert "flame-effect" in action.help + assert "media-light" in action.help + assert "media-color" in action.help + assert "overhead-light" in action.help + assert "overhead-color" in action.help + assert "ambient-sensor" in action.help + break + else: + pytest.fail("param action not found in set subparser") + + def test_set_value_help(self): + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_set = choices["set"] + for action in sp_set._actions: + if action.dest == "value": + assert action.help == "Value to set" + break + else: + pytest.fail("value action not found in set subparser") + + def test_subparsers_dest_is_command(self): + parser = build_parser() + sp_action = parser._subparsers._group_actions[0] + assert sp_action.dest == "command" + + def test_set_has_three_positional_args(self): + """set subparser has fire_id, param, value.""" + parser = build_parser() + choices = parser._subparsers._group_actions[0].choices + sp_set = choices["set"] + positional_dests = [a.dest for a in sp_set._actions if not a.option_strings] + assert "fire_id" in positional_dests + assert "param" in positional_dests + assert "value" in positional_dests + + +# =================================================================== +# Mutation-killing tests: _display_mode precise output +# =================================================================== + + +class TestDisplayModeMutants: + """Precise output checks for _display_mode to kill string mutants.""" + + def test_header_exact(self, capsys): + param = _make_mode_param() + _display_mode(param) + out = capsys.readouterr().out + assert "[321] Mode" in out + assert "─" * 40 in out + + def test_label_mode(self, capsys): + param = _make_mode_param(mode=FireMode.MANUAL) + _display_mode(param) + out = capsys.readouterr().out + assert " Mode: On" in out + + def test_label_target_temp(self, capsys): + param = _make_mode_param(target_temperature=20.0) + _display_mode(param) + out = capsys.readouterr().out + assert " Target Temp: 20.0°" in out + + def test_no_unit_suffix_when_none(self, capsys): + param = _make_mode_param(target_temperature=20.0) + _display_mode(param) + out = capsys.readouterr().out + # When temp_unit is None, suffix should be empty string + assert "20.0°\n" in out or "20.0°" in out + assert "°C" not in out + assert "°F" not in out + + def test_celsius_suffix(self, capsys): + param = _make_mode_param(target_temperature=20.0) + tu = TempUnitParam(unit=TempUnit.CELSIUS) + _display_mode(param, tu) + out = capsys.readouterr().out + assert "20.0°C" in out + + def test_fahrenheit_suffix(self, capsys): + param = _make_mode_param(target_temperature=20.0) + tu = TempUnitParam(unit=TempUnit.FAHRENHEIT) + _display_mode(param, tu) + out = capsys.readouterr().out + assert "°F" in out + + +# =================================================================== +# Mutation-killing tests: _display_flame_effect precise output +# =================================================================== + + +class TestDisplayFlameEffectMutants: + """Precise output checks for _display_flame_effect.""" + + def test_header(self, capsys): + param = _make_flame_effect_param() + _display_flame_effect(param) + out = capsys.readouterr().out + assert "[322] Flame Effect" in out + assert "─" * 40 in out + + def test_flame_label(self, capsys): + param = _make_flame_effect_param(flame_effect=FlameEffect.ON) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Flame: On" in out + + def test_flame_speed_label(self, capsys): + param = _make_flame_effect_param(flame_speed=3) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Flame Speed: 3 / 5" in out + + def test_brightness_label(self, capsys): + param = _make_flame_effect_param(brightness=Brightness.HIGH) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Brightness: High" in out + + def test_flame_color_label(self, capsys): + param = _make_flame_effect_param(flame_color=FlameColor.ALL) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Flame Color: All" in out + + def test_media_light_label(self, capsys): + param = _make_flame_effect_param( + media_theme=MediaTheme.USER_DEFINED, + media_color=RGBWColor(red=10, green=20, blue=30, white=40), + ) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Media Light: User Defined | RGBW(10, 20, 30, 40)" in out + + def test_overhead_light_label(self, capsys): + param = _make_flame_effect_param(light_status=LightStatus.OFF) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Overhead Light: Off" in out + + def test_overhead_pulsating_label(self, capsys): + param = _make_flame_effect_param(pulsating_effect=PulsatingEffect.OFF) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Overhead Pulsating: Off" in out + + def test_overhead_color_label(self, capsys): + param = _make_flame_effect_param( + overhead_color=RGBWColor(red=50, green=60, blue=70, white=80) + ) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Overhead Color: RGBW(50, 60, 70, 80)" in out + + def test_ambient_sensor_label(self, capsys): + param = _make_flame_effect_param(ambient_sensor=LightStatus.ON) + _display_flame_effect(param) + out = capsys.readouterr().out + assert " Ambient Sensor: On" in out + + +# =================================================================== +# Mutation-killing tests: _display_heat precise output +# =================================================================== + + +class TestDisplayHeatMutants: + """Precise output checks for _display_heat.""" + + def test_header(self, capsys): + param = _make_heat_param() + _display_heat(param) + out = capsys.readouterr().out + assert "[323] Heat Settings" in out + assert "─" * 40 in out + + def test_heat_label(self, capsys): + param = _make_heat_param(heat_status=HeatStatus.ON) + _display_heat(param) + out = capsys.readouterr().out + assert " Heat: On" in out + + def test_heat_mode_label(self, capsys): + param = _make_heat_param(heat_mode=HeatMode.NORMAL) + _display_heat(param) + out = capsys.readouterr().out + assert " Heat Mode: Normal" in out + + def test_setpoint_temp_label(self, capsys): + param = _make_heat_param(setpoint_temperature=22.0) + _display_heat(param) + out = capsys.readouterr().out + assert " Setpoint Temp: 22.0°" in out + + def test_boost_duration_label(self, capsys): + param = _make_heat_param(boost_duration=15) + _display_heat(param) + out = capsys.readouterr().out + assert " Boost Duration: 15" in out + + def test_no_unit_suffix_when_none(self, capsys): + param = _make_heat_param(setpoint_temperature=22.0) + _display_heat(param) + out = capsys.readouterr().out + assert "22.0°\n" in out or out.count("°C") == 0 + + def test_celsius_unit(self, capsys): + param = _make_heat_param(setpoint_temperature=22.0) + tu = TempUnitParam(unit=TempUnit.CELSIUS) + _display_heat(param, tu) + out = capsys.readouterr().out + assert "22.0°C" in out + + def test_fahrenheit_unit(self, capsys): + param = _make_heat_param(setpoint_temperature=22.0) + tu = TempUnitParam(unit=TempUnit.FAHRENHEIT) + _display_heat(param, tu) + out = capsys.readouterr().out + assert "°F" in out + + +# =================================================================== +# Mutation-killing tests: _display_heat_mode precise output +# =================================================================== + + +class TestDisplayHeatModeMutants: + """Precise output checks for _display_heat_mode.""" + + def test_header(self, capsys): + param = HeatModeParam(heat_control=HeatControl.ENABLED) + _display_heat_mode(param) + out = capsys.readouterr().out + assert "[325] Heat Mode" in out + assert "─" * 40 in out + + def test_heat_control_label(self, capsys): + param = HeatModeParam(heat_control=HeatControl.ENABLED) + _display_heat_mode(param) + out = capsys.readouterr().out + assert " Heat Control: Enabled" in out + + +# =================================================================== +# Mutation-killing tests: _display_timer precise output +# =================================================================== + + +class TestDisplayTimerMutants: + """Precise tests for _display_timer to kill arithmetic mutants.""" + + def test_header(self, capsys): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=0) + _display_timer(param) + out = capsys.readouterr().out + assert "[326] Timer Mode" in out + assert "─" * 40 in out + + def test_timer_label(self, capsys): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=0) + _display_timer(param) + out = capsys.readouterr().out + assert " Timer: Disabled" in out + + def test_duration_90_minutes(self, capsys): + """90 // 60 = 1, 90 % 60 = 30. Kills //2 and %2 mutants.""" + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=90) + _display_timer(param) + out = capsys.readouterr().out + assert " Duration: 90 min (1h 30m)" in out + + def test_duration_120_minutes(self, capsys): + """120 // 60 = 2, 120 % 60 = 0. Kills //2 (=60) and %2 (=0) mutants.""" + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=120) + _display_timer(param) + out = capsys.readouterr().out + assert " Duration: 120 min (2h 0m)" in out + + def test_duration_45_minutes(self, capsys): + """45 // 60 = 0, 45 % 60 = 45. Different from //2=22, %2=1.""" + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=45) + _display_timer(param) + out = capsys.readouterr().out + assert " Duration: 45 min (0h 45m)" in out + + def test_duration_61_minutes(self, capsys): + """61 // 60 = 1, 61 % 60 = 1. With //2 it'd be 30, %2 it'd be 1.""" + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=61) + _display_timer(param) + out = capsys.readouterr().out + assert " Duration: 61 min (1h 1m)" in out + + def test_off_at_shown_when_enabled_with_duration(self, capsys): + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=30) + _display_timer(param) + out = capsys.readouterr().out + assert " Off at:" in out + + def test_off_at_not_shown_when_disabled(self, capsys): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=30) + _display_timer(param) + out = capsys.readouterr().out + assert "Off at:" not in out + + def test_off_at_not_shown_when_enabled_zero(self, capsys): + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=0) + _display_timer(param) + out = capsys.readouterr().out + assert "Off at:" not in out + + def test_off_at_format(self, capsys): + """Verify the Off at line includes HH:MM format.""" + from datetime import datetime, timedelta + + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=60) + _display_timer(param) + out = capsys.readouterr().out + expected_time = datetime.now() + timedelta(minutes=60) + expected_str = expected_time.strftime("%H:%M") + assert expected_str in out + + +# =================================================================== +# Mutation-killing tests: _display_software_version precise output +# =================================================================== + + +class TestDisplaySoftwareVersionMutants: + """Precise output checks for _display_software_version.""" + + def test_header(self, capsys): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + _display_software_version(param) + out = capsys.readouterr().out + assert "[327] Software Version" in out + assert "─" * 40 in out + + def test_ui_version_label(self, capsys): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + _display_software_version(param) + out = capsys.readouterr().out + assert " UI Version: 1.2.3" in out + + def test_control_version_label(self, capsys): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + _display_software_version(param) + out = capsys.readouterr().out + assert " Control Version: 4.5.6" in out + + def test_relay_version_label(self, capsys): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + _display_software_version(param) + out = capsys.readouterr().out + assert " Relay Version: 7.8.9" in out + + def test_version_components_distinct(self, capsys): + """Ensure each version component is from the correct field.""" + param = SoftwareVersionParam( + ui_major=10, + ui_minor=20, + ui_test=30, + control_major=40, + control_minor=50, + control_test=60, + relay_major=70, + relay_minor=80, + relay_test=90, + ) + _display_software_version(param) + out = capsys.readouterr().out + assert "10.20.30" in out + assert "40.50.60" in out + assert "70.80.90" in out + + +# =================================================================== +# Mutation-killing tests: _display_error precise output +# =================================================================== + + +class TestDisplayErrorMutants: + """Precise output checks for _display_error.""" + + def test_header(self, capsys): + param = ErrorParam(error_byte1=0, error_byte2=0, error_byte3=0, error_byte4=0) + _display_error(param) + out = capsys.readouterr().out + assert "[329] Error" in out + assert "─" * 40 in out + + def test_error_byte_hex_and_binary(self, capsys): + param = ErrorParam( + error_byte1=0xFF, error_byte2=0, error_byte3=0, error_byte4=0 + ) + _display_error(param) + out = capsys.readouterr().out + assert "0xFF" in out + assert "11111111" in out + + def test_error_byte_labels_with_numbers(self, capsys): + param = ErrorParam(error_byte1=1, error_byte2=2, error_byte3=4, error_byte4=8) + _display_error(param) + out = capsys.readouterr().out + assert " Error Byte 1: 0x01 (00000001)" in out + assert " Error Byte 2: 0x02 (00000010)" in out + assert " Error Byte 3: 0x04 (00000100)" in out + assert " Error Byte 4: 0x08 (00001000)" in out + + def test_active_faults_yes(self, capsys): + param = ErrorParam(error_byte1=0, error_byte2=0, error_byte3=0, error_byte4=1) + _display_error(param) + out = capsys.readouterr().out + assert " Active Faults: Yes" in out + + def test_active_faults_none(self, capsys): + param = ErrorParam(error_byte1=0, error_byte2=0, error_byte3=0, error_byte4=0) + _display_error(param) + out = capsys.readouterr().out + assert " Active Faults: None" in out + + def test_all_bytes_zero_format(self, capsys): + param = ErrorParam(error_byte1=0, error_byte2=0, error_byte3=0, error_byte4=0) + _display_error(param) + out = capsys.readouterr().out + assert " Error Byte 1: 0x00 (00000000)" in out + assert " Error Byte 2: 0x00 (00000000)" in out + assert " Error Byte 3: 0x00 (00000000)" in out + assert " Error Byte 4: 0x00 (00000000)" in out + + def test_enumerate_start_1(self, capsys): + """Ensure enumeration starts at 1, not 0 or 2.""" + param = ErrorParam( + error_byte1=0xAA, error_byte2=0xBB, error_byte3=0xCC, error_byte4=0xDD + ) + _display_error(param) + out = capsys.readouterr().out + assert "Error Byte 1:" in out + assert "Error Byte 2:" in out + assert "Error Byte 3:" in out + assert "Error Byte 4:" in out + assert "Error Byte 0:" not in out + assert "Error Byte 5:" not in out + + def test_error_only_byte2_set(self, capsys): + """Faults detected via OR of all bytes.""" + param = ErrorParam( + error_byte1=0, + error_byte2=0x10, + error_byte3=0, + error_byte4=0, + ) + _display_error(param) + out = capsys.readouterr().out + assert " Active Faults: Yes" in out + + def test_error_only_byte3_set(self, capsys): + param = ErrorParam( + error_byte1=0, + error_byte2=0, + error_byte3=0x01, + error_byte4=0, + ) + _display_error(param) + out = capsys.readouterr().out + assert " Active Faults: Yes" in out + + +# =================================================================== +# Mutation-killing tests: _display_temp_unit precise output +# =================================================================== + + +class TestDisplayTempUnitMutants: + """Precise output checks for _display_temp_unit.""" + + def test_header(self, capsys): + param = TempUnitParam(unit=TempUnit.CELSIUS) + _display_temp_unit(param) + out = capsys.readouterr().out + assert "[236] Temperature Unit" in out + assert "─" * 40 in out + + def test_unit_label(self, capsys): + param = TempUnitParam(unit=TempUnit.CELSIUS) + _display_temp_unit(param) + out = capsys.readouterr().out + assert " Unit: Celsius" in out + + def test_unit_fahrenheit_label(self, capsys): + param = TempUnitParam(unit=TempUnit.FAHRENHEIT) + _display_temp_unit(param) + out = capsys.readouterr().out + assert " Unit: Fahrenheit" in out + + +# =================================================================== +# Mutation-killing tests: _display_sound precise output +# =================================================================== + + +class TestDisplaySoundMutants: + """Precise output checks for _display_sound.""" + + def test_header(self, capsys): + param = SoundParam(volume=128, sound_file=3) + _display_sound(param) + out = capsys.readouterr().out + assert "[369] Sound" in out + assert "─" * 40 in out + + def test_volume_label(self, capsys): + param = SoundParam(volume=128, sound_file=3) + _display_sound(param) + out = capsys.readouterr().out + assert " Volume: 128 / 255" in out + + def test_sound_file_label(self, capsys): + param = SoundParam(volume=128, sound_file=3) + _display_sound(param) + out = capsys.readouterr().out + assert " Sound File: 3" in out + + def test_different_values(self, capsys): + param = SoundParam(volume=0, sound_file=7) + _display_sound(param) + out = capsys.readouterr().out + assert " Volume: 0 / 255" in out + assert " Sound File: 7" in out + + +# =================================================================== +# Mutation-killing tests: _display_log_effect precise output +# =================================================================== + + +class TestDisplayLogEffectMutants: + """Precise output checks for _display_log_effect.""" + + def test_header(self, capsys): + param = LogEffectParam(log_effect=LogEffect.ON, color=_DEFAULT_RGBW, pattern=0) + _display_log_effect(param) + out = capsys.readouterr().out + assert "[370] Log Effect" in out + assert "─" * 40 in out + + def test_log_effect_label(self, capsys): + param = LogEffectParam(log_effect=LogEffect.ON, color=_DEFAULT_RGBW, pattern=0) + _display_log_effect(param) + out = capsys.readouterr().out + assert " Log Effect: On" in out + + def test_colors_label(self, capsys): + param = LogEffectParam( + log_effect=LogEffect.ON, + color=RGBWColor(red=1, green=2, blue=3, white=4), + pattern=0, + ) + _display_log_effect(param) + out = capsys.readouterr().out + assert " Colors: RGBW(1, 2, 3, 4)" in out + + def test_pattern_label(self, capsys): + param = LogEffectParam(log_effect=LogEffect.ON, color=_DEFAULT_RGBW, pattern=5) + _display_log_effect(param) + out = capsys.readouterr().out + assert " Pattern: 5" in out + + +# =================================================================== +# Mutation-killing tests: _display_features precise output +# =================================================================== + + +class TestDisplayFeaturesMutants: + """Precise output checks for _display_features.""" + + def test_header(self, capsys): + _display_features(FireFeatures()) + out = capsys.readouterr().out + assert "Supported Features" in out + assert "─" * 40 in out + + def test_yes_no_values(self, capsys): + features = FireFeatures(sound=True, advanced_heat=False) + _display_features(features) + out = capsys.readouterr().out + assert "Sound:" in out + assert "Advanced Heat:" in out + lines = out.strip().split("\n") + for line in lines: + if "Sound:" in line: + assert "Yes" in line + if "Advanced Heat:" in line: + assert "No" in line + + def test_all_feature_labels_present(self, capsys): + """Verify every label from _FEATURE_LABELS is present.""" + features = FireFeatures( + sound=True, + simple_heat=True, + advanced_heat=True, + seven_day_timer=True, + count_down_timer=True, + moods=True, + flame_height=True, + rgb_flame_accent=True, + flame_dimming=True, + rgb_fuel_bed=True, + fuel_bed_dimming=True, + flame_fan_speed=True, + rgb_back_light=True, + front_light_amber=True, + pir_toggle_smart_sense=True, + lgt1_to_5=True, + requires_warm_up=True, + apply_flame_only_first=True, + flame_amber=True, + check_if_remote_was_used=True, + media_accent=True, + power_boost=True, + fan_only=True, + rgb_log_effect=True, + ) + _display_features(features) + out = capsys.readouterr().out + expected = [ + "Sound:", + "Simple Heat:", + "Advanced Heat:", + "7-Day Timer:", + "Countdown Timer:", + "Moods:", + "Flame Height:", + "RGB Flame Accent:", + "Flame Dimming:", + "RGB Fuel Bed:", + "Fuel Bed Dimming:", + "Flame Fan Speed:", + "RGB Back Light:", + "Front Light Amber:", + "PIR Smart Sense:", + "LGT 1-5:", + "Requires Warm Up:", + "Apply Flame Only First:", + "Flame Amber:", + "Check If Remote Was Used:", + "Media Accent:", + "Power Boost:", + "Fan Only:", + "RGB Log Effect:", + ] + for label in expected: + assert label in out, f"Missing label: {label}" + assert out.count("Yes") == 24 + + def test_field_value_alignment(self, capsys): + """Test that labels are left-aligned with :<28s formatting.""" + features = FireFeatures(sound=True) + _display_features(features) + out = capsys.readouterr().out + assert "Sound: Yes" in out + + +# =================================================================== +# Mutation-killing tests: _masked_input +# =================================================================== + + +class TestMaskedInputMutants: + """Precise tests for _masked_input to kill surviving mutants.""" + + @staticmethod + def _run_masked_with_mocks( + chars: list[str], prompt: str = "Password: " + ) -> tuple[str, MagicMock, MagicMock, MagicMock, MagicMock]: + """Run _masked_input with mocked terminal I/O.""" + from flameconnect.cli import _masked_input + + mock_stdin = MagicMock() + mock_stdout = MagicMock() + mock_stdin.read.side_effect = chars + mock_stdin.fileno.return_value = 0 + + mock_termios = MagicMock() + mock_termios.tcgetattr.return_value = ["old_settings"] + mock_termios.TCSADRAIN = 1 + mock_tty = MagicMock() + + with ( + patch("sys.stdin", mock_stdin), + patch("sys.stdout", mock_stdout), + patch.dict("sys.modules", {"termios": mock_termios, "tty": mock_tty}), + ): + result = _masked_input(prompt) + + return result, mock_stdin, mock_stdout, mock_termios, mock_tty + + def test_prompt_written(self): + """Prompt is written to stdout.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks( + ["\n"], prompt="Enter: " + ) + mock_stdout.write.assert_any_call("Enter: ") + + def test_asterisks_written(self): + """Each character should produce a '*' on stdout.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks( + ["a", "b", "c", "\n"] + ) + write_calls = [c.args[0] for c in mock_stdout.write.call_args_list] + assert write_calls.count("*") == 3 + + def test_backspace_sequence_written(self): + """Backspace should write '\\b \\b' sequence.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks(["a", "\x7f", "\n"]) + mock_stdout.write.assert_any_call("\b \b") + + def test_backspace_on_empty_no_write(self): + """Backspace on empty input should not write anything extra.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks(["\x7f", "\n"]) + write_calls = [c.args[0] for c in mock_stdout.write.call_args_list] + assert "\b \b" not in write_calls + + def test_newline_written_at_end(self): + """After the loop, a newline is written.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks(["a", "\n"]) + write_calls = [c.args[0] for c in mock_stdout.write.call_args_list] + assert write_calls[-1] == "\n" + + def test_termios_restored(self): + """Terminal settings are restored in finally block.""" + result, mock_stdin, _, mock_termios, _ = self._run_masked_with_mocks( + ["a", "\n"] + ) + mock_termios.tcsetattr.assert_called_once_with( + 0, mock_termios.TCSADRAIN, ["old_settings"] + ) + + def test_tty_setraw_called(self): + """tty.setraw is called with stdin fd.""" + result, _, _, _, mock_tty = self._run_masked_with_mocks(["a", "\n"]) + mock_tty.setraw.assert_called_once_with(0) + + def test_fileno_called(self): + """stdin.fileno() is called to get the file descriptor.""" + result, mock_stdin, _, _, _ = self._run_masked_with_mocks(["a", "\n"]) + mock_stdin.fileno.assert_called_once() + + def test_flush_called_for_prompt(self): + """stdout.flush() called after writing the prompt.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks(["\n"]) + assert mock_stdout.flush.call_count >= 2 # prompt + final newline + + def test_flush_called_for_each_char(self): + """stdout.flush() called for each character typed.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks(["a", "b", "\n"]) + # prompt flush + 2 char flushes + final newline flush = at least 4 + assert mock_stdout.flush.call_count >= 4 + + def test_ctrl_c_raises_keyboard_interrupt(self): + from flameconnect.cli import _masked_input + + mock_stdin = MagicMock() + mock_stdout = MagicMock() + mock_stdin.read.side_effect = ["\x03"] + mock_stdin.fileno.return_value = 0 + + mock_termios = MagicMock() + mock_termios.tcgetattr.return_value = ["old"] + mock_termios.TCSADRAIN = 1 + mock_tty = MagicMock() + + with ( + patch("sys.stdin", mock_stdin), + patch("sys.stdout", mock_stdout), + patch.dict("sys.modules", {"termios": mock_termios, "tty": mock_tty}), + pytest.raises(KeyboardInterrupt), + ): + _masked_input() + + def test_ctrl_c_still_restores_termios(self): + """Even on Ctrl-C, terminal settings should be restored.""" + from flameconnect.cli import _masked_input + + mock_stdin = MagicMock() + mock_stdout = MagicMock() + mock_stdin.read.side_effect = ["\x03"] + mock_stdin.fileno.return_value = 0 + + mock_termios = MagicMock() + mock_termios.tcgetattr.return_value = ["saved"] + mock_termios.TCSADRAIN = 1 + mock_tty = MagicMock() + + with ( + patch("sys.stdin", mock_stdin), + patch("sys.stdout", mock_stdout), + patch.dict("sys.modules", {"termios": mock_termios, "tty": mock_tty}), + pytest.raises(KeyboardInterrupt), + ): + _masked_input() + + mock_termios.tcsetattr.assert_called_once_with( + 0, mock_termios.TCSADRAIN, ["saved"] + ) + + def test_carriage_return_ends_input(self): + result, _, _, _, _ = self._run_masked_with_mocks(["x", "y", "\r"]) + assert result == "xy" + + def test_newline_ends_input(self): + result, _, _, _, _ = self._run_masked_with_mocks(["x", "y", "\n"]) + assert result == "xy" + + def test_delete_char_0x08(self): + """\\x08 (BS) also works as backspace.""" + result, _, mock_stdout, _, _ = self._run_masked_with_mocks( + ["a", "b", "\x08", "c", "\n"] + ) + assert result == "ac" + mock_stdout.write.assert_any_call("\b \b") + + def test_empty_input(self): + result, _, _, _, _ = self._run_masked_with_mocks(["\n"]) + assert result == "" + + def test_custom_prompt(self): + result, _, mock_stdout, _, _ = self._run_masked_with_mocks( + ["\n"], prompt="Secret: " + ) + mock_stdout.write.assert_any_call("Secret: ") + + def test_default_prompt(self): + result, _, mock_stdout, _, _ = self._run_masked_with_mocks( + ["\n"], prompt="Password: " + ) + mock_stdout.write.assert_any_call("Password: ") + + def test_multiple_backspaces(self): + """Multiple backspaces remove multiple chars.""" + result, _, _, _, _ = self._run_masked_with_mocks( + ["a", "b", "c", "\x7f", "\x7f", "\n"] + ) + assert result == "a" + + def test_tcgetattr_called_before_setraw(self): + """tcgetattr is called to save settings before setraw.""" + result, _, _, mock_termios, mock_tty = self._run_masked_with_mocks(["\n"]) + mock_termios.tcgetattr.assert_called_once_with(0) + + def test_return_value_is_join(self): + """Return value is the joined chars list.""" + result, _, _, _, _ = self._run_masked_with_mocks( + ["h", "e", "l", "l", "o", "\n"] + ) + assert result == "hello" + + def test_read_one_char_at_a_time(self): + """stdin.read is called with 1 for each character.""" + result, mock_stdin, _, _, _ = self._run_masked_with_mocks(["a", "b", "\n"]) + for call in mock_stdin.read.call_args_list: + assert call.args[0] == 1 + + +# =================================================================== +# Mutation-killing tests: main +# =================================================================== + + +class TestMainMutants: + """Precise tests for main() to kill remaining mutants.""" + + def test_main_calls_parse_args(self): + """Ensure parser.parse_args() is called.""" + with ( + patch("flameconnect.cli.build_parser") as mock_parser_fn, + patch("flameconnect.cli.asyncio"), + patch("flameconnect.cli.async_main", new=MagicMock()), + ): + mock_parser = MagicMock() + mock_args = argparse.Namespace(command="list", verbose=False) + mock_parser.parse_args.return_value = mock_args + mock_parser_fn.return_value = mock_parser + + main() + + mock_parser.parse_args.assert_called_once() + + def test_main_verbose_sets_debug(self): + """Verbose flag should set logging to DEBUG.""" + import logging as real_logging + + with ( + patch("flameconnect.cli.build_parser") as mock_parser_fn, + patch("flameconnect.cli.asyncio"), + patch("flameconnect.cli.async_main", new=MagicMock()), + patch("flameconnect.cli.logging") as mock_logging, + ): + mock_parser = MagicMock() + mock_args = argparse.Namespace(command="list", verbose=True) + mock_parser.parse_args.return_value = mock_args + mock_parser_fn.return_value = mock_parser + mock_logging.DEBUG = real_logging.DEBUG + mock_logging.WARNING = real_logging.WARNING + + main() + + mock_logging.basicConfig.assert_called_once_with(level=real_logging.DEBUG) + + def test_main_no_verbose_sets_warning(self): + """No verbose flag should set logging to WARNING.""" + import logging as real_logging + + with ( + patch("flameconnect.cli.build_parser") as mock_parser_fn, + patch("flameconnect.cli.asyncio"), + patch("flameconnect.cli.async_main", new=MagicMock()), + patch("flameconnect.cli.logging") as mock_logging, + ): + mock_parser = MagicMock() + mock_args = argparse.Namespace(command="list", verbose=False) + mock_parser.parse_args.return_value = mock_args + mock_parser_fn.return_value = mock_parser + mock_logging.DEBUG = real_logging.DEBUG + mock_logging.WARNING = real_logging.WARNING + + main() + + mock_logging.basicConfig.assert_called_once_with(level=real_logging.WARNING) + + def test_main_passes_args_to_async_main(self): + """async_main receives the parsed args.""" + with ( + patch("flameconnect.cli.build_parser") as mock_parser_fn, + patch("flameconnect.cli.asyncio") as mock_asyncio, + patch("flameconnect.cli.async_main") as mock_async_main, + ): + mock_parser = MagicMock() + mock_args = argparse.Namespace(command="status", verbose=False) + mock_parser.parse_args.return_value = mock_args + mock_parser_fn.return_value = mock_parser + + main() + + mock_asyncio.run.assert_called_once() + mock_async_main.assert_called_once_with(mock_args) diff --git a/tests/test_client.py b/tests/test_client.py index fbcc089..567fc81 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import dataclasses import json import logging from pathlib import Path @@ -13,7 +14,11 @@ from yarl import URL from flameconnect.auth import TokenAuth -from flameconnect.client import FlameConnectClient, _get_parameter_id +from flameconnect.client import ( + FlameConnectClient, + _get_parameter_id, + _parse_fire_features, +) from flameconnect.const import API_BASE, DEFAULT_HEADERS from flameconnect.exceptions import ApiError from flameconnect.models import ( @@ -1472,3 +1477,683 @@ async def test_normal_fire_id_unchanged( overview = await client.get_fire_overview(fire_id) assert overview.fire.fire_id == fire_id + + +# ------------------------------------------------------------------- +# Direct unit tests for _parse_fire_features +# ------------------------------------------------------------------- + +_FEATURES_MAP = [ + ("Sound", "sound"), + ("SimpleHeat", "simple_heat"), + ("AdvancedHeat", "advanced_heat"), + ("SevenDayTimer", "seven_day_timer"), + ("CountDownTimer", "count_down_timer"), + ("Moods", "moods"), + ("FlameHeight", "flame_height"), + ("RgbFlameAccent", "rgb_flame_accent"), + ("FlameDimming", "flame_dimming"), + ("RgbFuelBed", "rgb_fuel_bed"), + ("FuelBedDimming", "fuel_bed_dimming"), + ("FlameFanSpeed", "flame_fan_speed"), + ("RgbBackLight", "rgb_back_light"), + ("FrontLightAmber", "front_light_amber"), + ("PirToggleSmartSense", "pir_toggle_smart_sense"), + ("Lgt1To5", "lgt1_to_5"), + ("RequiresWarmUp", "requires_warm_up"), + ("ApplyFlameOnlyFirst", "apply_flame_only_first"), + ("FlameAmber", "flame_amber"), + ("CheckIfRemoteWasUsed", "check_if_remote_was_used"), + ("MediaAccent", "media_accent"), + ("PowerBoost", "power_boost"), + ("FanOnly", "fan_only"), + ("RgbLogEffect", "rgb_log_effect"), +] + + +class TestParseFireFeaturesDirect: + """Direct unit tests for _parse_fire_features to kill mutants.""" + + def test_all_features_true(self): + """When all JSON keys are True, every field must be True.""" + data = {json_key: True for json_key, _ in _FEATURES_MAP} + result = _parse_fire_features(data) + for _, attr_name in _FEATURES_MAP: + assert getattr(result, attr_name) is True, f"{attr_name} should be True" + + def test_empty_dict_all_false(self): + """When given an empty dict, every field defaults to False.""" + result = _parse_fire_features({}) + for field in dataclasses.fields(result): + assert getattr(result, field.name) is False, f"{field.name} should be False" + + @pytest.mark.parametrize( + ("json_key", "attr_name"), + _FEATURES_MAP, + ids=[attr for _, attr in _FEATURES_MAP], + ) + def test_single_feature_true(self, json_key: str, attr_name: str): + """Setting only one JSON key True must set only that attribute.""" + result = _parse_fire_features({json_key: True}) + assert getattr(result, attr_name) is True + for field in dataclasses.fields(result): + if field.name != attr_name: + assert getattr(result, field.name) is False, ( + f"{field.name} should be False when only {attr_name} is True" + ) + + +# ------------------------------------------------------------------- +# Direct unit tests for get_fire_overview feature sourcing +# ------------------------------------------------------------------- + + +class TestGetFireOverviewFeatureSource: + """Test FireFeature data sourcing: FireDetails vs WifiFireOverview.""" + + async def test_features_from_fire_details(self, mock_api, token_auth): + """FireDetails.FireFeature is the primary source for features.""" + fire_id = "feat-src" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = { + "FireDetails": { + "FireFeature": {"Sound": True, "SimpleHeat": True}, + }, + "WifiFireOverview": { + "FireId": fire_id, + "FireFeature": {"Moods": True}, + "Parameters": [], + }, + } + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.features.sound is True + assert overview.fire.features.simple_heat is True + # WifiFireOverview.FireFeature should NOT be used + assert overview.fire.features.moods is False + + async def test_features_fallback_to_wifi(self, mock_api, token_auth): + """When FireDetails has no FireFeature, fall back to WifiFireOverview.""" + fire_id = "feat-fallback" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = { + "WifiFireOverview": { + "FireId": fire_id, + "FireFeature": {"Moods": True, "FanOnly": True}, + "Parameters": [], + }, + } + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.features.moods is True + assert overview.fire.features.fan_only is True + assert overview.fire.features.sound is False + + async def test_features_fallback_when_fire_details_empty( + self, mock_api, token_auth + ): + """When FireDetails exists but FireFeature is empty, fall back.""" + fire_id = "feat-empty-details" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = { + "FireDetails": {"FireFeature": {}}, + "WifiFireOverview": { + "FireId": fire_id, + "FireFeature": {"PowerBoost": True}, + "Parameters": [], + }, + } + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.features.power_boost is True + + +# ------------------------------------------------------------------- +# Direct unit tests for get_fires field mapping +# ------------------------------------------------------------------- + + +class TestGetFiresFieldMapping: + """Test every Fire field is correctly mapped from JSON.""" + + async def test_all_fire_fields(self, mock_api, token_auth): + """Verify each Fire field is mapped from the correct JSON key.""" + url = f"{API_BASE}/api/Fires/GetFires" + payload = [ + { + "FireId": "fire-abc", + "FriendlyName": "Kitchen", + "Brand": "Faber", + "ProductType": "Symphony XT", + "ProductModel": "SYM-30", + "ItemCode": "XYZ789", + "IoTConnectionState": 0, + "WithHeat": False, + "IsIotFire": False, + "FireFeature": { + "Sound": True, + "FlameHeight": True, + }, + } + ] + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + fires = await client.get_fires() + + assert len(fires) == 1 + fire = fires[0] + assert fire.fire_id == "fire-abc" + assert fire.friendly_name == "Kitchen" + assert fire.brand == "Faber" + assert fire.product_type == "Symphony XT" + assert fire.product_model == "SYM-30" + assert fire.item_code == "XYZ789" + assert fire.connection_state == ConnectionState.UNKNOWN + assert fire.with_heat is False + assert fire.is_iot_fire is False + assert fire.features.sound is True + assert fire.features.flame_height is True + assert fire.features.moods is False + + async def test_get_fires_missing_fire_feature_key(self, mock_api, token_auth): + """When FireFeature key is absent, features default to all False.""" + url = f"{API_BASE}/api/Fires/GetFires" + payload = [ + { + "FireId": "no-feat", + "FriendlyName": "Office", + "Brand": "Real Flame", + "ProductType": "PT", + "ProductModel": "PM", + "ItemCode": "IC", + "IoTConnectionState": 2, + "WithHeat": True, + "IsIotFire": True, + } + ] + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + fires = await client.get_fires() + + fire = fires[0] + assert fire.features == FireFeatures() + + async def test_get_fires_multiple_entries(self, mock_api, token_auth): + """Multiple fires are all parsed.""" + url = f"{API_BASE}/api/Fires/GetFires" + entry = { + "FireId": "f", + "FriendlyName": "n", + "Brand": "b", + "ProductType": "pt", + "ProductModel": "pm", + "ItemCode": "ic", + "IoTConnectionState": 0, + "WithHeat": False, + "IsIotFire": False, + } + mock_api.get(url, payload=[entry, {**entry, "FireId": "f2"}]) + + async with FlameConnectClient(token_auth) as client: + fires = await client.get_fires() + + assert len(fires) == 2 + assert fires[0].fire_id == "f" + assert fires[1].fire_id == "f2" + + +# ------------------------------------------------------------------- +# Direct unit tests for _request boundary checks +# ------------------------------------------------------------------- + + +class TestRequestBoundaryStatus: + """Test _request status code boundary checks.""" + + async def test_status_199_raises(self, mock_api, token_auth): + """Status 199 (< 200) must raise ApiError.""" + url = f"{API_BASE}/api/Fires/GetFires" + mock_api.get(url, status=199, body="Too early") + + async with FlameConnectClient(token_auth) as client: + with pytest.raises(ApiError) as exc_info: + await client.get_fires() + + assert exc_info.value.status == 199 + + async def test_status_200_succeeds(self, mock_api, token_auth): + """Status 200 must succeed.""" + url = f"{API_BASE}/api/Fires/GetFires" + mock_api.get(url, status=200, payload=[]) + + async with FlameConnectClient(token_auth) as client: + fires = await client.get_fires() + + assert fires == [] + + async def test_status_299_succeeds(self, mock_api, token_auth): + """Status 299 is still 2xx and must succeed.""" + url = f"{API_BASE}/api/Fires/GetFires" + mock_api.get(url, status=299, payload=[]) + + async with FlameConnectClient(token_auth) as client: + fires = await client.get_fires() + + assert fires == [] + + async def test_status_300_raises(self, mock_api, token_auth): + """Status 300 (>= 300) must raise ApiError.""" + url = f"{API_BASE}/api/Fires/GetFires" + mock_api.get(url, status=300, body="Redirect") + + async with FlameConnectClient(token_auth) as client: + with pytest.raises(ApiError) as exc_info: + await client.get_fires() + + assert exc_info.value.status == 300 + + +# ------------------------------------------------------------------- +# Direct unit tests for write_parameters payload +# ------------------------------------------------------------------- + + +class TestWriteParametersPayload: + """Test exact payload structure of write_parameters.""" + + async def test_payload_has_fire_id_and_parameters(self, mock_api, token_auth): + """Verify the top-level keys of the POST body.""" + url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.post(url, payload={}) + + mode = ModeParam(mode=FireMode.MANUAL, target_temperature=22.0) + async with FlameConnectClient(token_auth) as client: + await client.write_parameters("fire-xyz", [mode]) + + key = ("POST", URL(url)) + body = mock_api.requests[key][0].kwargs["json"] + assert body["FireId"] == "fire-xyz" + assert "Parameters" in body + assert len(body["Parameters"]) == 1 + + async def test_wire_param_structure(self, mock_api, token_auth): + """Each wire param must have ParameterId (int) and Value (str).""" + url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.post(url, payload={}) + + sound = SoundParam(volume=50, sound_file=1) + async with FlameConnectClient(token_auth) as client: + await client.write_parameters("fire-xyz", [sound]) + + key = ("POST", URL(url)) + body = mock_api.requests[key][0].kwargs["json"] + wire = body["Parameters"][0] + assert wire["ParameterId"] == 369 + assert isinstance(wire["Value"], str) + # Value must be valid base64 + base64.b64decode(wire["Value"]) + + async def test_multiple_wire_params_order(self, mock_api, token_auth): + """Multiple params produce multiple wire entries.""" + url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.post(url, payload={}) + + mode = ModeParam(mode=FireMode.MANUAL, target_temperature=22.0) + temp_unit = TempUnitParam(unit=TempUnit.CELSIUS) + async with FlameConnectClient(token_auth) as client: + await client.write_parameters("f1", [mode, temp_unit]) + + key = ("POST", URL(url)) + body = mock_api.requests[key][0].kwargs["json"] + ids = [p["ParameterId"] for p in body["Parameters"]] + assert ids == [321, 236] + + async def test_write_url_exact(self, mock_api, token_auth): + """Verify the exact URL used for write_parameters.""" + url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.post(url, payload={}) + + mode = ModeParam(mode=FireMode.MANUAL, target_temperature=22.0) + async with FlameConnectClient(token_auth) as client: + await client.write_parameters("f1", [mode]) + + key = ("POST", URL(url)) + assert key in mock_api.requests + + +# ------------------------------------------------------------------- +# Direct unit tests for turn_on / turn_off +# ------------------------------------------------------------------- + + +class TestTurnOnDirect: + """Direct tests for turn_on covering mutant scenarios.""" + + async def test_turn_on_mode_is_manual( + self, mock_api, token_auth, get_fire_overview_payload + ): + """turn_on must set mode to MANUAL.""" + fire_id = "test-fire-001" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.get(overview_url, payload=get_fire_overview_payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_on(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + mode_wire = next(p for p in body["Parameters"] if p["ParameterId"] == 321) + raw = base64.b64decode(mode_wire["Value"]) + assert raw[3] == FireMode.MANUAL + + async def test_turn_on_writes_to_correct_fire_id( + self, mock_api, token_auth, get_fire_overview_payload + ): + """turn_on must write to the correct fire_id.""" + fire_id = "test-fire-001" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.get(overview_url, payload=get_fire_overview_payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_on(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + assert body["FireId"] == fire_id + + async def test_turn_on_with_only_mode_no_flame(self, mock_api, token_auth): + """When overview has ModeParam but no FlameEffectParam, only mode is written.""" + fire_id = "mode-only" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mode_val = encode_parameter( + ModeParam(mode=FireMode.STANDBY, target_temperature=25.0) + ) + payload = _make_overview_payload( + fire_id=fire_id, + parameters=[{"ParameterId": 321, "Value": mode_val}], + ) + mock_api.get(overview_url, payload=payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_on(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + # Only ModeParam written + assert len(body["Parameters"]) == 1 + assert body["Parameters"][0]["ParameterId"] == 321 + # Temperature preserved from existing ModeParam + raw = base64.b64decode(body["Parameters"][0]["Value"]) + temp = float(raw[4]) + float(raw[5]) / 10.0 + assert temp == pytest.approx(25.0) + + +class TestTurnOffDirect: + """Direct tests for turn_off covering mutant scenarios.""" + + async def test_turn_off_mode_is_standby( + self, mock_api, token_auth, get_fire_overview_payload + ): + """turn_off must set mode to STANDBY.""" + fire_id = "test-fire-001" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.get(overview_url, payload=get_fire_overview_payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_off(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + assert len(body["Parameters"]) == 1 + raw = base64.b64decode(body["Parameters"][0]["Value"]) + assert raw[3] == FireMode.STANDBY + + async def test_turn_off_writes_to_correct_fire_id( + self, mock_api, token_auth, get_fire_overview_payload + ): + """turn_off must write to the correct fire_id.""" + fire_id = "test-fire-001" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.get(overview_url, payload=get_fire_overview_payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_off(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + assert body["FireId"] == fire_id + + async def test_turn_off_only_writes_mode_param( + self, mock_api, token_auth, get_fire_overview_payload + ): + """turn_off only writes ModeParam, not FlameEffectParam.""" + fire_id = "test-fire-001" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mock_api.get(overview_url, payload=get_fire_overview_payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_off(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + assert len(body["Parameters"]) == 1 + assert body["Parameters"][0]["ParameterId"] == 321 + + async def test_turn_off_break_not_continue(self, mock_api, token_auth): + """turn_off breaks after finding ModeParam (doesn't continue scanning). + + When overview has ModeParam and FlameEffectParam, turn_off should + use the ModeParam temperature and only write one param. + """ + fire_id = "multi-param" + overview_url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + write_url = f"{API_BASE}/api/Fires/WriteWifiParameters" + mode_val = encode_parameter( + ModeParam(mode=FireMode.MANUAL, target_temperature=19.0) + ) + flame_val = encode_parameter( + FlameEffectParam( + flame_effect=FlameEffect.ON, + flame_speed=3, + brightness=Brightness.LOW, + pulsating_effect=PulsatingEffect.OFF, + media_theme=MediaTheme.USER_DEFINED, + media_light=LightStatus.OFF, + media_color=RGBWColor(0, 0, 0, 0), + overhead_light=LightStatus.OFF, + overhead_color=RGBWColor(0, 0, 0, 0), + light_status=LightStatus.OFF, + flame_color=FlameEffect.OFF, + ambient_sensor=LightStatus.OFF, + ) + ) + payload = _make_overview_payload( + fire_id=fire_id, + parameters=[ + {"ParameterId": 321, "Value": mode_val}, + {"ParameterId": 322, "Value": flame_val}, + ], + ) + mock_api.get(overview_url, payload=payload) + mock_api.post(write_url, payload={}) + + async with FlameConnectClient(token_auth) as client: + await client.turn_off(fire_id) + + key = ("POST", URL(write_url)) + body = mock_api.requests[key][0].kwargs["json"] + raw = base64.b64decode(body["Parameters"][0]["Value"]) + temp = float(raw[4]) + float(raw[5]) / 10.0 + assert temp == pytest.approx(19.0) + + +# ------------------------------------------------------------------- +# Direct unit tests for __init__, __aenter__, __aexit__ +# ------------------------------------------------------------------- + + +class TestContextManagerDirect: + """Direct tests for __init__, __aenter__, __aexit__ mutants.""" + + async def test_aenter_creates_session_when_none(self, token_auth): + """__aenter__ must create a session when none is provided.""" + client = FlameConnectClient(token_auth) + assert client._session is None + async with client: + assert client._session is not None + assert isinstance(client._session, aiohttp.ClientSession) + + async def test_aenter_returns_self(self, token_auth): + """__aenter__ must return the client itself.""" + client = FlameConnectClient(token_auth) + async with client as returned: + assert returned is client + + async def test_aenter_preserves_external_session(self, token_auth): + """__aenter__ must not replace an externally provided session.""" + session = aiohttp.ClientSession() + try: + client = FlameConnectClient(token_auth, session=session) + async with client: + assert client._session is session + finally: + await session.close() + + async def test_init_none_session_stored_as_none(self, token_auth): + """When no session provided, _session is None before __aenter__.""" + client = FlameConnectClient(token_auth) + assert client._session is None + assert client._external_session is False + + async def test_init_auth_stored(self, token_auth): + """Auth object must be stored.""" + client = FlameConnectClient(token_auth) + assert client._auth is token_auth + + +# ------------------------------------------------------------------- +# Direct unit tests for get_fire_overview field defaults +# ------------------------------------------------------------------- + + +class TestGetFireOverviewFieldDefaults: + """Test each Fire field default in get_fire_overview individually.""" + + async def test_friendly_name_defaults_to_fire_id(self, mock_api, token_auth): + """FriendlyName defaults to FireId when missing.""" + fire_id = "id-as-name" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.friendly_name == fire_id + + async def test_brand_defaults_to_empty(self, mock_api, token_auth): + """Brand defaults to empty string when missing.""" + fire_id = "no-brand" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.brand == "" + + async def test_product_type_defaults_to_empty(self, mock_api, token_auth): + """ProductType defaults to empty string when missing.""" + fire_id = "no-pt" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.product_type == "" + + async def test_product_model_defaults_to_empty(self, mock_api, token_auth): + """ProductModel defaults to empty string when missing.""" + fire_id = "no-pm" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.product_model == "" + + async def test_item_code_defaults_to_empty(self, mock_api, token_auth): + """ItemCode defaults to empty string when missing.""" + fire_id = "no-ic" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.item_code == "" + + async def test_connection_state_defaults_to_unknown(self, mock_api, token_auth): + """IoTConnectionState defaults to 0 (UNKNOWN) when missing.""" + fire_id = "no-conn" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.connection_state == ConnectionState.UNKNOWN + + async def test_with_heat_defaults_to_false(self, mock_api, token_auth): + """WithHeat defaults to False when missing.""" + fire_id = "no-heat" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.with_heat is False + + async def test_is_iot_fire_defaults_to_false(self, mock_api, token_auth): + """IsIotFire defaults to False when missing.""" + fire_id = "no-iot" + url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}" + payload = {"WifiFireOverview": {"FireId": fire_id}} + mock_api.get(url, payload=payload) + + async with FlameConnectClient(token_auth) as client: + overview = await client.get_fire_overview(fire_id) + + assert overview.fire.is_iot_fire is False diff --git a/tests/test_models.py b/tests/test_models.py index 20963ba..8b207b1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -26,6 +26,7 @@ TimerStatus, convert_temp, display_name, + kebab_name, temp_suffix, ) @@ -287,3 +288,23 @@ def test_temp_unit(self): def test_flame_color(self): assert display_name(FlameColor.YELLOW_RED) == "Yellow/Red" + + +# --------------------------------------------------------------------------- +# kebab_name utility +# --------------------------------------------------------------------------- + + +class TestKebabName: + """Tests for kebab_name().""" + + def test_multi_word(self): + assert kebab_name(HeatMode.FAN_ONLY) == "fan-only" + + def test_single_word(self): + assert kebab_name(HeatMode.NORMAL) == "normal" + + def test_contains_hyphen_not_underscore(self): + result = kebab_name(HeatMode.FAN_ONLY) + assert "-" in result + assert "_" not in result diff --git a/tests/test_protocol.py b/tests/test_protocol.py index bac97a6..31f4600 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -38,6 +38,8 @@ TimerStatus, ) from flameconnect.protocol import ( + _check_length, + _decode_temperature, _encode_temperature, decode_parameter, encode_parameter, @@ -1631,3 +1633,123 @@ def test_encode_returns_valid_base64_ascii(self): # Verify it's valid base64 by round-tripping raw = base64.b64decode(result) assert len(raw) > 0 + + +# --------------------------------------------------------------------------- +# _decode_temperature / _check_length / _encode_temperature private helpers +# --------------------------------------------------------------------------- + + +class TestDecodeTemperature: + """Tests for _decode_temperature private helper.""" + + def test_fractional_at_offset_zero(self): + assert _decode_temperature(bytes([22, 5, 0, 0, 0]), 0) == 22.5 + + def test_fractional_at_offset_two(self): + assert _decode_temperature(bytes([0, 0, 22, 5, 0]), 2) == 22.5 + + def test_non_zero_decimal(self): + assert _decode_temperature(bytes([18, 3, 0]), 0) == 18.3 + + +class TestCheckLength: + """Tests for _check_length private helper.""" + + def test_exact_match_does_not_raise(self): + _check_length(bytes([1, 2, 3, 4]), 4, "Test") + + def test_shorter_raises(self): + with pytest.raises(ProtocolError, match="Test"): + _check_length(bytes([1, 2, 3]), 4, "Test") + + def test_error_message_contains_counts(self): + with pytest.raises(ProtocolError, match="expected 4 bytes, got 3"): + _check_length(bytes([1, 2, 3]), 4, "Test") + + +class TestEncodeTemperatureModTwo: + """Kill mutant that changes temp % 1 to temp % 2.""" + + def test_small_integer_plus_half(self): + # 1.5 % 1 = 0.5 → int(0.5*10) = 5; 1.5 % 2 = 1.5 → int(1.5*10) = 15 + assert _encode_temperature(1.5) == bytes([1, 5]) + + def test_fractional_three(self): + assert _encode_temperature(21.3) == bytes([21, 3]) + + +# --------------------------------------------------------------------------- +# Decoder mutant-killing tests +# --------------------------------------------------------------------------- + + +class TestDecodeTempUnitValue: + """Kill mutant that returns TempUnitParam(unit=None).""" + + def test_decoded_unit_is_celsius(self): + header = struct.pack("> 1) & 1 to | 1.""" + + def test_pulsating_off_when_bit1_clear(self): + # brightness_byte = 0 → brightness=HIGH(0), pulsating=(0>>1)&1=0=OFF + header = struct.pack(" short hash.""" + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=0, stdout="abc1234\n") + status = MagicMock(returncode=0, stdout="") + mock_run.side_effect = [tag, hashcmd, status] + result = _resolve_version() + assert result == "abc1234" + + @patch("flameconnect.tui.app.subprocess.run") + def test_hash_dirty(self, mock_run): + """Dirty tree appends -dirty.""" + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=0, stdout="abc1234\n") + status = MagicMock(returncode=0, stdout=" M file.py\n") + mock_run.side_effect = [tag, hashcmd, status] + result = _resolve_version() + assert result == "abc1234-dirty" + assert result.endswith("-dirty") + + @patch("flameconnect.tui.app.subprocess.run") + def test_hash_failure_fallback(self, mock_run): + from flameconnect import __version__ + + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=128, stdout="") + mock_run.side_effect = [tag, hashcmd] + result = _resolve_version() + assert result == f"v{__version__}" + + @patch("flameconnect.tui.app.subprocess.run") + def test_exception_fallback(self, mock_run): + from flameconnect import __version__ + + mock_run.side_effect = FileNotFoundError("git not found") + result = _resolve_version() + assert result == f"v{__version__}" + + @patch("flameconnect.tui.app.subprocess.run") + def test_hash_cmd_args(self, mock_run): + """Verify args for git rev-parse call.""" + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=0, stdout="abc1234\n") + status = MagicMock(returncode=0, stdout="") + mock_run.side_effect = [tag, hashcmd, status] + _resolve_version() + call_args = mock_run.call_args_list[1] + assert call_args[0][0] == ["git", "rev-parse", "--short", "HEAD"] + assert call_args[1]["capture_output"] is True + assert call_args[1]["text"] is True + assert call_args[1]["timeout"] == 2 + + @patch("flameconnect.tui.app.subprocess.run") + def test_status_cmd_args(self, mock_run): + """Verify args for git status call.""" + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=0, stdout="abc1234\n") + status = MagicMock(returncode=0, stdout="") + mock_run.side_effect = [tag, hashcmd, status] + _resolve_version() + call_args = mock_run.call_args_list[2] + assert call_args[0][0] == ["git", "status", "--porcelain"] + assert call_args[1]["capture_output"] is True + assert call_args[1]["text"] is True + assert call_args[1]["timeout"] == 2 + + @patch("flameconnect.tui.app.subprocess.run") + def test_tag_no_match_falls_through(self, mock_run): + """Tag succeeds but version not in output -> continues to hash.""" + tag = MagicMock(returncode=0, stdout="v99.99.99\n") + hashcmd = MagicMock(returncode=0, stdout="def5678\n") + status = MagicMock(returncode=0, stdout="") + mock_run.side_effect = [tag, hashcmd, status] + result = _resolve_version() + assert result == "def5678" + + @patch("flameconnect.tui.app.subprocess.run") + def test_status_failure_returns_clean_hash(self, mock_run): + """Status command fails -> no -dirty suffix.""" + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=0, stdout="abc1234\n") + status = MagicMock(returncode=1, stdout="") + mock_run.side_effect = [tag, hashcmd, status] + result = _resolve_version() + assert result == "abc1234" + assert "-dirty" not in result + + @patch("flameconnect.tui.app.subprocess.run") + def test_strip_applied_to_hash(self, mock_run): + """Verify strip() is applied to hash output.""" + tag = MagicMock(returncode=1, stdout="") + hashcmd = MagicMock(returncode=0, stdout=" abc1234 \n") + status = MagicMock(returncode=0, stdout="") + mock_run.side_effect = [tag, hashcmd, status] + result = _resolve_version() + assert result == "abc1234" + + @patch("flameconnect.tui.app.subprocess.run") + def test_strip_applied_to_tag(self, mock_run): + """Verify strip() is applied to tag output.""" + from flameconnect import __version__ + + mock_run.return_value = MagicMock(returncode=0, stdout=f" v{__version__} \n") + result = _resolve_version() + assert result == f"v{__version__}" diff --git a/tests/test_widgets_format.py b/tests/test_widgets_format.py index b4e132c..6c7f861 100644 --- a/tests/test_widgets_format.py +++ b/tests/test_widgets_format.py @@ -25,6 +25,8 @@ from unittest.mock import MagicMock, patch +from rich.text import Text as _Text + from flameconnect.models import ( Brightness, ConnectionState, @@ -56,6 +58,8 @@ temp_suffix, ) from flameconnect.tui.widgets import ( + _build_fire_art, + _expand_flame, _format_connection_state, _format_error, _format_flame_effect, @@ -1124,3 +1128,717 @@ def test_construction_no_action(self): widget = ClickableParam("Label: ", "Value") assert widget._action is None + + +# --------------------------------------------------------------------------- +# Exact label / value / action tests to kill mutation survivors +# --------------------------------------------------------------------------- + + +class TestFormatFlameEffectExactLabels: + """Exact label, value, and action checks for _format_flame_effect.""" + + def test_exact_label_flame_effect(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[0].label == "[bold]Flame Effect:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_label_flame_color(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[1].label == " Flame Color: " + assert not result[1].label.startswith("XX") + + def test_exact_label_speed(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[2].label == " Speed: " + assert not result[2].label.startswith("XX") + + def test_exact_label_brightness(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[3].label == " Brightness: " + assert not result[3].label.startswith("XX") + + def test_exact_label_media_theme(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[4].label == " Media Theme: " + assert not result[4].label.startswith("XX") + + def test_exact_label_media_light(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[5].label == " Media Light: " + assert not result[5].label.startswith("XX") + + def test_exact_label_media_color(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[6].label == " Media Color: " + assert not result[6].label.startswith("XX") + + def test_exact_label_overhead_light(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[7].label == " Overhead Light: " + assert not result[7].label.startswith("XX") + + def test_exact_label_overhead_pulsating(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[8].label == " Overhead Pulsating: " + assert not result[8].label.startswith("XX") + + def test_exact_label_overhead_color(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[9].label == " Overhead Color: " + assert not result[9].label.startswith("XX") + + def test_exact_label_ambient_sensor(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[10].label == " Ambient Sensor: " + assert not result[10].label.startswith("XX") + + def test_exact_actions_all(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert result[0].action == "toggle_flame_effect" + assert result[1].action == "set_flame_color" + assert result[2].action == "set_flame_speed" + assert result[3].action == "toggle_brightness" + assert result[4].action == "set_media_theme" + assert result[5].action == "toggle_media_light" + assert result[6].action == "set_media_color" + assert result[7].action == "toggle_overhead_light" + assert result[8].action == "toggle_pulsating" + assert result[9].action == "set_overhead_color" + assert result[10].action == "toggle_ambient_sensor" + + def test_exact_values_on(self): + param = _sample_flame_effect( + flame_effect=FlameEffect.ON, + flame_color=FlameColor.ALL, + flame_speed=3, + brightness=Brightness.HIGH, + media_theme=MediaTheme.WHITE, + media_light=LightStatus.ON, + light_status=LightStatus.ON, + pulsating_effect=PulsatingEffect.OFF, + ambient_sensor=LightStatus.OFF, + ) + result = _format_flame_effect(param) + assert result[0].value == "On" + assert result[1].value == "All" + assert result[2].value == "3/5" + assert result[3].value == "High" + assert result[4].value == "White" + assert result[5].value == "On" + assert result[7].value == "On" + assert result[8].value == "Off" + assert result[10].value == "Off" + + def test_speed_format_with_max(self): + """Speed should use MAX_FLAME_SPEED constant.""" + from flameconnect.const import MAX_FLAME_SPEED + + param = _sample_flame_effect(flame_speed=1) + result = _format_flame_effect(param) + assert result[2].value == f"1/{MAX_FLAME_SPEED}" + + def test_count_items(self): + param = _sample_flame_effect() + result = _format_flame_effect(param) + assert len(result) == 11 + + +class TestFormatHeatExactLabels: + """Exact label checks for _format_heat.""" + + def test_exact_heat_label(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=0, + ) + result = _format_heat(param) + assert result[0].label == "[bold]Heat:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_mode_label(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=0, + ) + result = _format_heat(param) + assert result[1].label == " Mode: " + assert not result[1].label.startswith("XX") + + def test_exact_setpoint_label(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=0, + ) + result = _format_heat(param) + assert result[2].label == " Setpoint: " + assert not result[2].label.startswith("XX") + + def test_exact_boost_label(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=0, + ) + result = _format_heat(param) + assert result[3].label == " Boost: " + assert not result[3].label.startswith("XX") + + def test_exact_actions(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=0, + ) + result = _format_heat(param) + assert result[0].action == "toggle_heat" + assert result[1].action == "set_heat_mode" + assert result[2].action == "set_heat_mode" + assert result[3].action == "set_heat_mode" + + def test_boost_value_when_boost(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.BOOST, + setpoint_temperature=22.0, + boost_duration=20, + ) + result = _format_heat(param) + assert result[3].value == "20min" + + def test_boost_value_when_not_boost(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=20, + ) + result = _format_heat(param) + assert result[3].value == "Off" + + def test_count_items(self): + param = HeatParam( + heat_status=HeatStatus.ON, + heat_mode=HeatMode.NORMAL, + setpoint_temperature=22.0, + boost_duration=0, + ) + result = _format_heat(param) + assert len(result) == 4 + + +class TestFormatConnectionStateExact: + """Exact format checks for _format_connection_state.""" + + def test_connected_exact_format(self): + result = _format_connection_state(ConnectionState.CONNECTED) + assert result == "[green]Connected[/green]" + + def test_not_connected_exact_format(self): + result = _format_connection_state(ConnectionState.NOT_CONNECTED) + assert result == "[red]Not Connected[/red]" + + def test_updating_firmware_exact_format(self): + result = _format_connection_state(ConnectionState.UPDATING_FIRMWARE) + assert result == "[yellow]Updating Firmware[/yellow]" + + def test_unknown_exact_format(self): + result = _format_connection_state(ConnectionState.UNKNOWN) + assert result == "[dim]Unknown[/dim]" + + +class TestFormatTimerExact: + """Exact label/action checks for _format_timer.""" + + def test_exact_label(self): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=0) + result = _format_timer(param) + assert result[0].label == "[bold]Timer:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_action(self): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=0) + result = _format_timer(param) + assert result[0].action == "toggle_timer" + + def test_value_format_disabled(self): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=10) + result = _format_timer(param) + assert result[0].value.startswith("Disabled") + assert "Duration: 10min" in result[0].value + + def test_value_format_enabled_with_off_at(self): + param = TimerParam(timer_status=TimerStatus.ENABLED, duration=30) + result = _format_timer(param) + assert result[0].value.startswith("Enabled") + assert "Duration: 30min" in result[0].value + assert "Off at" in result[0].value + + def test_count_items(self): + param = TimerParam(timer_status=TimerStatus.DISABLED, duration=0) + result = _format_timer(param) + assert len(result) == 1 + + +class TestFormatErrorExact: + """Exact label checks for _format_error.""" + + def test_no_error_exact_label(self): + param = ErrorParam(error_byte1=0, error_byte2=0, error_byte3=0, error_byte4=0) + result = _format_error(param) + assert result[0].label == "[bold]Errors:[/bold] " + assert result[0].value == "No Errors Recorded" + assert result[0].action is None + + def test_error_exact_label(self): + param = ErrorParam(error_byte1=1, error_byte2=0, error_byte3=0, error_byte4=0) + result = _format_error(param) + assert result[0].label == "[bold red]Error:[/bold red] " + assert result[0].action is None + + def test_error_exact_value_format(self): + param = ErrorParam( + error_byte1=0xAB, error_byte2=0xCD, error_byte3=0xEF, error_byte4=0x01 + ) + result = _format_error(param) + assert result[0].value == "0xAB 0xCD 0xEF 0x01" + + +class TestFormatModeExact: + """Exact label checks for _format_mode.""" + + def test_exact_mode_label(self): + param = ModeParam(mode=FireMode.STANDBY, target_temperature=20.0) + result = _format_mode(param) + assert result[0].label == "[bold]Mode:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_target_temp_label(self): + param = ModeParam(mode=FireMode.STANDBY, target_temperature=20.0) + result = _format_mode(param) + assert result[1].label == "[bold]Target Temp:[/bold] " + assert not result[1].label.startswith("XX") + + def test_exact_actions(self): + param = ModeParam(mode=FireMode.STANDBY, target_temperature=20.0) + result = _format_mode(param) + assert result[0].action == "toggle_power" + assert result[1].action == "set_temperature" + + +class TestFormatHeatModeExact: + """Exact label check for _format_heat_mode.""" + + def test_exact_label(self): + param = HeatModeParam(heat_control=HeatControl.ENABLED) + result = _format_heat_mode(param) + assert result[0].label == "[bold]Heat Control:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_action_none(self): + param = HeatModeParam(heat_control=HeatControl.ENABLED) + result = _format_heat_mode(param) + assert result[0].action is None + + +class TestFormatTempUnitExact: + """Exact label check for _format_temp_unit.""" + + def test_exact_label(self): + param = TempUnitParam(unit=TempUnit.CELSIUS) + result = _format_temp_unit(param) + assert result[0].label == "[bold]Temp Unit:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_action(self): + param = TempUnitParam(unit=TempUnit.CELSIUS) + result = _format_temp_unit(param) + assert result[0].action == "toggle_temp_unit" + + +class TestFormatSoundExact: + """Exact label check for _format_sound.""" + + def test_exact_label(self): + param = SoundParam(volume=5, sound_file=3) + result = _format_sound(param) + assert result[0].label == "[bold]Sound:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_action_none(self): + param = SoundParam(volume=5, sound_file=3) + result = _format_sound(param) + assert result[0].action is None + + def test_exact_value(self): + param = SoundParam(volume=5, sound_file=3) + result = _format_sound(param) + assert result[0].value == "Volume 5 File: 3" + + +class TestFormatSoftwareVersionExact: + """Exact label check for _format_software_version.""" + + def test_exact_label(self): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + result = _format_software_version(param) + assert result[0].label == "[bold]Software:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_value(self): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + result = _format_software_version(param) + assert result[0].value == "UI 1.2.3 Control 4.5.6 Relay 7.8.9" + + def test_exact_action_none(self): + param = SoftwareVersionParam( + ui_major=1, + ui_minor=2, + ui_test=3, + control_major=4, + control_minor=5, + control_test=6, + relay_major=7, + relay_minor=8, + relay_test=9, + ) + result = _format_software_version(param) + assert result[0].action is None + + +class TestFormatLogEffectExact: + """Exact label check for _format_log_effect.""" + + def test_exact_label(self): + color = RGBWColor(red=128, green=64, blue=32, white=16) + param = LogEffectParam(log_effect=LogEffect.ON, color=color, pattern=2) + result = _format_log_effect(param) + assert result[0].label == "[bold]Log Effect:[/bold] " + assert not result[0].label.startswith("XX") + + def test_exact_action_none(self): + color = _black() + param = LogEffectParam(log_effect=LogEffect.ON, color=color, pattern=0) + result = _format_log_effect(param) + assert result[0].action is None + + def test_exact_value(self): + color = RGBWColor(red=128, green=64, blue=32, white=16) + param = LogEffectParam(log_effect=LogEffect.ON, color=color, pattern=2) + result = _format_log_effect(param) + assert result[0].value == "On Color: R:128 G:64 B:32 W:16 Pattern: 2" + + +class TestClickableValueExact: + """Exact checks for _ClickableValue init to kill mutants.""" + + def test_action_stored(self): + from flameconnect.tui.widgets import _ClickableValue + + widget = _ClickableValue("test", action="my_action") + assert widget._action == "my_action" + assert "clickable" in widget.classes + + def test_no_action_no_class(self): + from flameconnect.tui.widgets import _ClickableValue + + widget = _ClickableValue("test") + assert widget._action is None + assert "clickable" not in widget.classes + + def test_empty_string_action(self): + from flameconnect.tui.widgets import _ClickableValue + + widget = _ClickableValue("test", action="") + assert widget._action == "" + assert "clickable" not in widget.classes + + +class TestFormatParametersExact: + """Additional exact checks for format_parameters.""" + + def test_empty_returns_no_params_message(self): + result = format_parameters([]) + assert len(result) == 1 + assert result[0].label == "[dim]No parameters available[/dim]" + assert result[0].value == "" + assert result[0].action is None + + def test_mode_dispatches_correctly(self): + params = [ModeParam(mode=FireMode.STANDBY, target_temperature=20.0)] + result = format_parameters(params) + assert result[0].label == "[bold]Mode:[/bold] " + assert result[0].value == "Standby" + + def test_temp_unit_forwarded_to_mode(self): + params = [ + TempUnitParam(unit=TempUnit.FAHRENHEIT), + ModeParam(mode=FireMode.MANUAL, target_temperature=100.0), + ] + result = format_parameters(params) + temp_row = [r for r in result if "Target Temp" in r.label][0] + assert "212.0\u00b0F" in temp_row.value + + +# --------------------------------------------------------------------------- +# _expand_flame +# --------------------------------------------------------------------------- + + +class TestExpandFlame: + """Tests for _expand_flame gap distribution logic.""" + + def test_basic_expansion(self): + atoms = [("A", 1), ("B", 1), ("C", 0)] + result = _expand_flame(atoms, 10, "red") + text = result.plain + assert "A" in text + assert "B" in text + assert "C" in text + assert len(text) == 10 + + def test_no_gap_weight(self): + atoms = [("ABC", 0)] + result = _expand_flame(atoms, 10, "red") + text = result.plain + assert text.startswith("ABC") + + def test_equal_weights(self): + atoms = [("X", 2), ("Y", 2), ("Z", 0)] + result = _expand_flame(atoms, 9, "blue") + text = result.plain + assert len(text) == 9 + assert text.startswith("X") + assert "Y" in text + assert text.endswith("Z") + + def test_body_width_equals_chars(self): + atoms = [("AB", 1), ("CD", 0)] + result = _expand_flame(atoms, 4, "red") + assert result.plain == "ABCD" + + def test_body_width_less_than_chars(self): + atoms = [("AB", 1), ("CD", 0)] + result = _expand_flame(atoms, 2, "red") + assert result.plain == "ABCD" + + def test_single_atom_with_gap(self): + atoms = [("X", 3)] + result = _expand_flame(atoms, 5, "green") + text = result.plain + assert text.startswith("X") + assert len(text) == 5 + + def test_proportional_gap_distribution(self): + atoms = [("A", 3), ("B", 1), ("C", 0)] + result = _expand_flame(atoms, 11, "red") + text = result.plain + assert len(text) == 11 + a_idx = text.index("A") + b_idx = text.index("B") + c_idx = text.index("C") + assert a_idx < b_idx < c_idx + + def test_style_applied(self): + atoms = [("A", 0)] + result = _expand_flame(atoms, 1, "bright_red") + assert isinstance(result, _Text) + + def test_zero_total_weight_fallback(self): + atoms = [("A", 0), ("B", 0)] + result = _expand_flame(atoms, 5, "red") + assert "A" in result.plain + assert "B" in result.plain + + def test_large_gap(self): + atoms = [("X", 1), ("Y", 0)] + result = _expand_flame(atoms, 20, "red") + assert len(result.plain) == 20 + + +# --------------------------------------------------------------------------- +# _build_fire_art +# --------------------------------------------------------------------------- + + +class TestBuildFireArt: + """Tests for _build_fire_art.""" + + def test_returns_text(self): + result = _build_fire_art(30, 20, fire_on=True) + assert isinstance(result, _Text) + + def test_fire_on_has_flame_chars(self): + result = _build_fire_art(30, 20, fire_on=True) + plain = result.plain + assert any(c in plain for c in ("(", ")", "\\", "/", "|")) + + def test_fire_off_no_flame_chars(self): + result = _build_fire_art(30, 20, fire_on=False) + plain = result.plain + lines = plain.split("\n") + for line in lines: + if "\u2591" in line or "\u2593" in line: + continue + if "\u2500" in line or "\u2581" in line: + continue + if "\u250c" in line or "\u2510" in line: + continue + if "\u2514" in line or "\u2518" in line: + continue + stripped = line.replace("\u2502", "").strip() + assert "(" not in stripped or stripped == "" + + def test_led_style_appears_in_output(self): + result = _build_fire_art(30, 20, fire_on=True, led_style="bright_green") + # The LED strip row uses led_style; verify the light-shade char exists + assert "\u2591" in result.plain + + def test_media_style_appears(self): + result = _build_fire_art(30, 20, fire_on=True, media_style="blue") + assert "\u2593" in result.plain + + def test_structure_top_edge(self): + result = _build_fire_art(30, 20, fire_on=True) + lines = result.plain.split("\n") + assert "\u2581" in lines[0] + + def test_structure_outer_frame_top(self): + result = _build_fire_art(30, 20, fire_on=True) + lines = result.plain.split("\n") + assert "\u250c" in lines[1] + assert "\u2510" in lines[1] + + def test_structure_inner_frame(self): + result = _build_fire_art(30, 20, fire_on=True) + lines = result.plain.split("\n") + assert "\u250c" in lines[2] + assert "\u2510" in lines[2] + + def test_structure_bottom(self): + result = _build_fire_art(30, 20, fire_on=True) + lines = result.plain.split("\n") + last = lines[-1] + assert "\u2514" in last + assert "\u2518" in last + + def test_anim_frame_changes_palette(self): + r0 = _build_fire_art(30, 20, fire_on=True, anim_frame=0) + r1 = _build_fire_art(30, 20, fire_on=True, anim_frame=1) + assert isinstance(r0, _Text) + assert isinstance(r1, _Text) + + def test_heat_on_adds_wave_chars(self): + result = _build_fire_art(30, 20, fire_on=True, heat_on=True) + plain = result.plain + assert "\u2248" in plain or "~" in plain + + def test_heat_off_no_wave_chars(self): + result = _build_fire_art(30, 20, fire_on=True, heat_on=False) + plain = result.plain + assert "\u2248" not in plain + assert "~" not in plain + + def test_small_height_minimum_flame_rows(self): + result = _build_fire_art(30, 10, fire_on=True) + assert isinstance(result, _Text) + lines = result.plain.split("\n") + assert len(lines) >= 10 + + def test_explicit_palette(self): + palette = ("bright_cyan", "bright_blue", "blue") + result = _build_fire_art(30, 20, fire_on=True, flame_palette=palette) + assert isinstance(result, _Text) + + def test_width_affects_output(self): + r1 = _build_fire_art(20, 20, fire_on=True) + r2 = _build_fire_art(40, 20, fire_on=True) + lines1 = r1.plain.split("\n") + lines2 = r2.plain.split("\n") + assert len(lines1[0]) < len(lines2[0]) + + def test_default_params_fire_on_true(self): + result = _build_fire_art(30, 20) + plain = result.plain + assert any(c in plain for c in ("(", ")", "\\", "/")) + + def test_fire_off_explicit(self): + result = _build_fire_art(30, 20, fire_on=False) + assert isinstance(result, _Text) + + def test_hearth_row_present(self): + result = _build_fire_art(30, 20, fire_on=True) + plain = result.plain + # Hearth uses dark shade chars + lines = plain.split("\n") + hearth_found = False + for line in lines: + if ( + "\u2502" in line + and "\u2593" in line + and "\u250c" not in line + and "\u2514" not in line + ): + hearth_found = True + break + assert hearth_found + + def test_inner_media_bed_present(self): + result = _build_fire_art(30, 20, fire_on=True) + plain = result.plain + assert "\u2593" in plain + + def test_led_strip_present(self): + result = _build_fire_art(30, 20, fire_on=True) + plain = result.plain + assert "\u2591" in plain + + def test_large_height(self): + result = _build_fire_art(30, 30, fire_on=True) + lines = result.plain.split("\n") + assert len(lines) >= 20 + + def test_heat_on_with_fire_off(self): + result = _build_fire_art(30, 20, fire_on=False, heat_on=True) + plain = result.plain + assert "\u2248" in plain or "~" in plain