"""pip_config 模块单测 — 纯逻辑,无外部依赖.""" from __future__ import annotations import pytest from packages.domain.pip_config import ( ANIMATION_FADE, ANIMATION_SCALE, ANIMATION_SLIDE_BOTTOM, ANIMATION_SLIDE_LEFT, ANIMATION_SLIDE_RIGHT, ANIMATION_SLIDE_TOP, POSITION_BOTTOM_LEFT, POSITION_BOTTOM_RIGHT, POSITION_CENTER, POSITION_CENTER_LEFT, POSITION_CENTER_RIGHT, POSITION_TOP_CENTER, POSITION_TOP_LEFT, POSITION_TOP_RIGHT, PiPConfig, PiPLayerConfig, calculate_pip_position, parse_size_value, ) # ── PiPLayerConfig 默认值 ──────────────────────────────────────────────────── class TestPiPLayerConfigDefaults: def test_default_values(self): cfg = PiPLayerConfig() assert cfg.source == "" assert cfg.source_type == "asset_id" assert cfg.position == POSITION_BOTTOM_RIGHT assert cfg.x == 0 assert cfg.y == 0 assert cfg.margin == 20 assert cfg.width == "25%" assert cfg.height == "" assert cfg.opacity == 1.0 assert cfg.corner_radius == 0 assert cfg.border_width == 0 assert cfg.border_color == "white" assert cfg.start_time == 0.0 assert cfg.duration == 0.0 assert cfg.animation_in == "" assert cfg.animation_out == "" assert cfg.animation_duration == 0.5 assert cfg.z_index == 1 # ── PiPLayerConfig.validate ────────────────────────────────────────────────── class TestPiPLayerConfigValidate: def test_valid_config(self): cfg = PiPLayerConfig(source="asset_123") ok, err = cfg.validate() assert ok is True assert err == "" def test_empty_source_invalid(self): cfg = PiPLayerConfig(source="") ok, err = cfg.validate() assert ok is False assert "source" in err def test_invalid_position(self): cfg = PiPLayerConfig(source="a", position="invalid_pos") ok, err = cfg.validate() assert ok is False assert "position" in err def test_custom_position_valid(self): cfg = PiPLayerConfig(source="a", position="custom", x=10, y=20) ok, err = cfg.validate() assert ok is True def test_opacity_too_low(self): cfg = PiPLayerConfig(source="a", opacity=-0.1) ok, err = cfg.validate() assert ok is False assert "opacity" in err def test_opacity_too_high(self): cfg = PiPLayerConfig(source="a", opacity=1.5) ok, err = cfg.validate() assert ok is False assert "opacity" in err def test_opacity_boundary_zero(self): cfg = PiPLayerConfig(source="a", opacity=0.0) ok, _ = cfg.validate() assert ok is True def test_opacity_boundary_one(self): cfg = PiPLayerConfig(source="a", opacity=1.0) ok, _ = cfg.validate() assert ok is True def test_negative_corner_radius(self): cfg = PiPLayerConfig(source="a", corner_radius=-5) ok, err = cfg.validate() assert ok is False assert "corner_radius" in err def test_negative_start_time(self): cfg = PiPLayerConfig(source="a", start_time=-1.0) ok, err = cfg.validate() assert ok is False assert "start_time" in err def test_negative_duration(self): cfg = PiPLayerConfig(source="a", duration=-2.0) ok, err = cfg.validate() assert ok is False assert "duration" in err def test_zero_duration_valid(self): cfg = PiPLayerConfig(source="a", duration=0.0) ok, _ = cfg.validate() assert ok is True def test_invalid_animation_in(self): cfg = PiPLayerConfig(source="a", animation_in="invalid") ok, err = cfg.validate() assert ok is False assert "入场动画" in err def test_invalid_animation_out(self): cfg = PiPLayerConfig(source="a", animation_out="invalid") ok, err = cfg.validate() assert ok is False assert "出场动画" in err def test_valid_animation_fade(self): cfg = PiPLayerConfig(source="a", animation_in=ANIMATION_FADE, animation_out=ANIMATION_FADE) ok, _ = cfg.validate() assert ok is True def test_valid_animation_slide(self): cfg = PiPLayerConfig( source="a", animation_in=ANIMATION_SLIDE_LEFT, animation_out=ANIMATION_SLIDE_RIGHT, ) ok, _ = cfg.validate() assert ok is True def test_valid_animation_scale(self): cfg = PiPLayerConfig(source="a", animation_in=ANIMATION_SCALE) ok, _ = cfg.validate() assert ok is True def test_empty_animation_valid(self): cfg = PiPLayerConfig(source="a", animation_in="", animation_out="") ok, _ = cfg.validate() assert ok is True def test_negative_animation_duration(self): cfg = PiPLayerConfig(source="a", animation_duration=-0.5) ok, err = cfg.validate() assert ok is False assert "animation_duration" in err # ── PiPConfig.from_dict ────────────────────────────────────────────────────── class TestPiPConfigFromDict: def test_none_data_disabled(self): cfg = PiPConfig.from_dict(None) assert cfg.enabled is False assert cfg.layers == [] def test_empty_dict_disabled(self): cfg = PiPConfig.from_dict({}) assert cfg.enabled is False assert cfg.layers == [] def test_enabled_false(self): cfg = PiPConfig.from_dict({"enabled": False, "layers": [{"source": "a"}]}) assert cfg.enabled is False assert cfg.layers == [] def test_single_layer(self): cfg = PiPConfig.from_dict( { "enabled": True, "layers": [{"source": "asset_1"}], } ) assert cfg.enabled is True assert cfg.layer_count == 1 assert cfg.layers[0].source == "asset_1" def test_multiple_layers_sorted_by_z_index(self): cfg = PiPConfig.from_dict( { "enabled": True, "layers": [ {"source": "top", "z_index": 10}, {"source": "bottom", "z_index": 1}, {"source": "mid", "z_index": 5}, ], } ) assert cfg.layer_count == 3 assert [layer.source for layer in cfg.layers] == ["bottom", "mid", "top"] def test_invalid_layer_skipped(self): cfg = PiPConfig.from_dict( { "enabled": True, "layers": [ {"source": "valid"}, {"source": ""}, # 无效:空source ], } ) assert cfg.layer_count == 1 assert cfg.layers[0].source == "valid" def test_all_invalid_layers_disabled(self): cfg = PiPConfig.from_dict( { "enabled": True, "layers": [ {"source": ""}, {"source": "", "opacity": 2.0}, ], } ) assert cfg.enabled is False assert cfg.layer_count == 0 def test_layer_parse_error_skipped(self): cfg = PiPConfig.from_dict( { "enabled": True, "layers": [ {"source": "valid"}, {"source": "bad_margin", "margin": "not_a_number"}, ], } ) assert cfg.layer_count == 1 def test_layer_full_fields(self): cfg = PiPConfig.from_dict( { "enabled": True, "layers": [ { "source": "asset_1", "source_type": "url", "position": "top_left", "x": 10, "y": 20, "margin": 30, "width": "30%", "height": "20%", "opacity": 0.8, "corner_radius": 10, "border_width": 2, "border_color": "black", "start_time": 1.5, "duration": 5.0, "animation_in": "fade", "animation_out": "slide_right", "animation_duration": 0.8, "z_index": 3, } ], } ) assert cfg.layer_count == 1 layer = cfg.layers[0] assert layer.source == "asset_1" assert layer.source_type == "url" assert layer.position == "top_left" assert layer.margin == 30 assert layer.width == "30%" assert layer.opacity == 0.8 assert layer.corner_radius == 10 assert layer.start_time == 1.5 assert layer.duration == 5.0 assert layer.animation_in == "fade" assert layer.z_index == 3 def test_empty_layers_list(self): cfg = PiPConfig.from_dict({"enabled": True, "layers": []}) assert cfg.enabled is False assert cfg.layer_count == 0 # ── PiPConfig 属性 ─────────────────────────────────────────────────────────── class TestPiPConfigProperties: def test_layer_count_empty(self): cfg = PiPConfig() assert cfg.layer_count == 0 def test_max_z_index_empty(self): cfg = PiPConfig() assert cfg.max_z_index == 0 def test_max_z_index_multiple(self): cfg = PiPConfig( layers=[ PiPLayerConfig(source="a", z_index=3), PiPLayerConfig(source="b", z_index=7), PiPLayerConfig(source="c", z_index=2), ] ) assert cfg.max_z_index == 7 # ── parse_size_value ───────────────────────────────────────────────────────── class TestParseSizeValue: def test_int_value(self): assert parse_size_value(100, 1920) == 100 def test_int_value_zero_bumped_to_one(self): assert parse_size_value(0, 1920) == 1 def test_int_negative_bumped_to_one(self): assert parse_size_value(-5, 1920) == 1 def test_percentage_string(self): assert parse_size_value("50%", 1920) == 960 def test_percentage_25pct(self): assert parse_size_value("25%", 1920) == 480 def test_percentage_small(self): assert parse_size_value("1%", 1920) == 19 def test_percentage_zero_bumped(self): assert parse_size_value("0%", 1920) == 1 def test_invalid_percentage_fallback(self): assert parse_size_value("abc%", 1920) == 480 # 25% default def test_numeric_string(self): assert parse_size_value("200", 1920) == 200 def test_invalid_string_fallback(self): assert parse_size_value("invalid", 1920) == 480 def test_custom_default_pct(self): assert parse_size_value("bad", 1000, default_pct=0.5) == 500 def test_none_fallback(self): assert parse_size_value(None, 1920) == 480 # type: ignore[arg-type] def test_float_int_conversion(self): # float 不是 int,会走到 try int(value) 分支 result = parse_size_value(150.0, 1920) # type: ignore[arg-type] assert result == 150 # ── calculate_pip_position ─────────────────────────────────────────────────── class TestCalculatePipPosition: W = 1920 H = 1080 PW = 300 # pip width PH = 200 # pip height M = 20 # margin def test_top_left(self): x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH, self.M) assert (x, y) == (20, 20) def test_top_center(self): x, y = calculate_pip_position(POSITION_TOP_CENTER, self.W, self.H, self.PW, self.PH, self.M) assert x == (self.W - self.PW) // 2 assert y == self.M def test_top_right(self): x, y = calculate_pip_position(POSITION_TOP_RIGHT, self.W, self.H, self.PW, self.PH, self.M) assert x == self.W - self.PW - self.M assert y == self.M def test_center_left(self): x, y = calculate_pip_position(POSITION_CENTER_LEFT, self.W, self.H, self.PW, self.PH, self.M) assert x == self.M assert y == (self.H - self.PH) // 2 def test_center(self): x, y = calculate_pip_position(POSITION_CENTER, self.W, self.H, self.PW, self.PH, self.M) assert x == (self.W - self.PW) // 2 assert y == (self.H - self.PH) // 2 def test_center_right(self): x, y = calculate_pip_position(POSITION_CENTER_RIGHT, self.W, self.H, self.PW, self.PH, self.M) assert x == self.W - self.PW - self.M assert y == (self.H - self.PH) // 2 def test_bottom_left(self): x, y = calculate_pip_position(POSITION_BOTTOM_LEFT, self.W, self.H, self.PW, self.PH, self.M) assert x == self.M assert y == self.H - self.PH - self.M def test_bottom_center(self): x, y = calculate_pip_position(POSITION_BOTTOM_RIGHT, self.W, self.H, self.PW, self.PH, self.M) # bottom_right 用作 fallback 默认值 assert x == self.W - self.PW - self.M assert y == self.H - self.PH - self.M def test_bottom_right(self): x, y = calculate_pip_position(POSITION_BOTTOM_RIGHT, self.W, self.H, self.PW, self.PH, self.M) assert x == self.W - self.PW - self.M assert y == self.H - self.PH - self.M def test_invalid_position_falls_back_to_bottom_right(self): x, y = calculate_pip_position("unknown_pos", self.W, self.H, self.PW, self.PH, self.M) assert x == self.W - self.PW - self.M assert y == self.H - self.PH - self.M def test_custom_int_coordinates(self): x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, custom_x=100, custom_y=200) assert (x, y) == (100, 200) def test_custom_percentage_coordinates(self): x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, custom_x="10%", custom_y="20%") assert x == int(1920 * 0.1) assert y == int(1080 * 0.2) def test_custom_zero_margin_ignored(self): # custom 模式下 margin 参数不影响 x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, margin=100, custom_x=50, custom_y=60) assert (x, y) == (50, 60) def test_default_margin(self): # margin 不传默认为 20 x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH) assert (x, y) == (20, 20) def test_large_margin(self): x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH, margin=50) assert (x, y) == (50, 50) def test_small_output_large_pip(self): # 极端情况:pip比输出还大,位置计算仍能给出值 x, y = calculate_pip_position(POSITION_CENTER, 100, 100, 200, 200, 10) assert x == (100 - 200) // 2 assert y == (100 - 200) // 2 # ── 常量导出验证 ───────────────────────────────────────────────────────────── class TestConstants: def test_nine_position_constants_exist(self): positions = [ POSITION_TOP_LEFT, POSITION_TOP_CENTER, POSITION_TOP_RIGHT, POSITION_CENTER_LEFT, POSITION_CENTER, POSITION_CENTER_RIGHT, POSITION_BOTTOM_LEFT, POSITION_BOTTOM_RIGHT, ] # bottom_center 也存在 from packages.domain.pip_config import POSITION_BOTTOM_CENTER positions.append(POSITION_BOTTOM_CENTER) assert len(positions) == 9 assert len(set(positions)) == 9 # 互不相同 def test_animation_constants_exist(self): animations = [ ANIMATION_FADE, ANIMATION_SLIDE_LEFT, ANIMATION_SLIDE_RIGHT, ANIMATION_SLIDE_TOP, ANIMATION_SLIDE_BOTTOM, ANIMATION_SCALE, ] assert len(set(animations)) == 6