|
36 | 36 |
|
37 | 37 | import pytest |
38 | 38 |
|
| 39 | +from esphome_device_builder.controllers import editor as editor_module |
39 | 40 | from esphome_device_builder.controllers.editor import ( |
40 | 41 | EditorController, |
41 | 42 | _EditorSession, |
@@ -799,3 +800,141 @@ async def _raise_runtime(*_args: Any, **_kwargs: Any) -> dict: |
799 | 800 | await controller.validate_yaml(configuration="kitchen.yaml", content="") |
800 | 801 |
|
801 | 802 | terminated.assert_awaited_once() |
| 803 | + |
| 804 | + |
| 805 | +# --------------------------------------------------------------------------- |
| 806 | +# validate_yaml — content-hash cache |
| 807 | +# --------------------------------------------------------------------------- |
| 808 | + |
| 809 | + |
| 810 | +@pytest.mark.asyncio |
| 811 | +async def test_validate_yaml_caches_result_for_repeated_content(tmp_path: Path) -> None: |
| 812 | + """A second call with identical content returns the cached result. |
| 813 | +
|
| 814 | + The linter and the save-time re-validate hit the same buffer |
| 815 | + back-to-back; the cache turns the second one into a no-op |
| 816 | + instead of paying for another ``esphome vscode`` round-trip. |
| 817 | + """ |
| 818 | + controller = _make_controller(tmp_path) |
| 819 | + calls: list[str] = [] |
| 820 | + |
| 821 | + async def _record(session: Any, configuration: str, content: str) -> dict: |
| 822 | + calls.append(content) |
| 823 | + return {"yaml_errors": [], "validation_errors": []} |
| 824 | + |
| 825 | + controller._validate_locked = _record # type: ignore[method-assign] |
| 826 | + |
| 827 | + first = await controller.validate_yaml( |
| 828 | + configuration="kitchen.yaml", content="esphome:\n name: kitchen\n" |
| 829 | + ) |
| 830 | + second = await controller.validate_yaml( |
| 831 | + configuration="kitchen.yaml", content="esphome:\n name: kitchen\n" |
| 832 | + ) |
| 833 | + |
| 834 | + assert first == second == {"yaml_errors": [], "validation_errors": []} |
| 835 | + assert len(calls) == 1 |
| 836 | + |
| 837 | + |
| 838 | +@pytest.mark.asyncio |
| 839 | +async def test_validate_yaml_cache_misses_on_different_content(tmp_path: Path) -> None: |
| 840 | + """Distinct content bypasses the cache and re-validates.""" |
| 841 | + controller = _make_controller(tmp_path) |
| 842 | + calls: list[str] = [] |
| 843 | + |
| 844 | + async def _record(session: Any, configuration: str, content: str) -> dict: |
| 845 | + calls.append(content) |
| 846 | + return {"yaml_errors": [], "validation_errors": []} |
| 847 | + |
| 848 | + controller._validate_locked = _record # type: ignore[method-assign] |
| 849 | + |
| 850 | + await controller.validate_yaml( |
| 851 | + configuration="kitchen.yaml", content="esphome:\n name: kitchen\n" |
| 852 | + ) |
| 853 | + await controller.validate_yaml( |
| 854 | + configuration="kitchen.yaml", content="esphome:\n name: kitchen-2\n" |
| 855 | + ) |
| 856 | + |
| 857 | + assert calls == ["esphome:\n name: kitchen\n", "esphome:\n name: kitchen-2\n"] |
| 858 | + |
| 859 | + |
| 860 | +@pytest.mark.asyncio |
| 861 | +async def test_validate_yaml_cache_expires_after_ttl( |
| 862 | + tmp_path: Path, monkeypatch: pytest.MonkeyPatch |
| 863 | +) -> None: |
| 864 | + """The cache TTL bounds staleness of ``!include`` / external-component reads.""" |
| 865 | + controller = _make_controller(tmp_path) |
| 866 | + calls: list[str] = [] |
| 867 | + |
| 868 | + async def _record(session: Any, configuration: str, content: str) -> dict: |
| 869 | + calls.append(content) |
| 870 | + return {"yaml_errors": [], "validation_errors": []} |
| 871 | + |
| 872 | + controller._validate_locked = _record # type: ignore[method-assign] |
| 873 | + fake_now = {"t": 1000.0} |
| 874 | + monkeypatch.setattr(editor_module.time, "monotonic", lambda: fake_now["t"]) |
| 875 | + |
| 876 | + await controller.validate_yaml( |
| 877 | + configuration="kitchen.yaml", content="esphome:\n name: kitchen\n" |
| 878 | + ) |
| 879 | + fake_now["t"] += editor_module._VALIDATE_CACHE_TTL + 1 |
| 880 | + await controller.validate_yaml( |
| 881 | + configuration="kitchen.yaml", content="esphome:\n name: kitchen\n" |
| 882 | + ) |
| 883 | + |
| 884 | + assert len(calls) == 2 |
| 885 | + |
| 886 | + |
| 887 | +@pytest.mark.asyncio |
| 888 | +async def test_validate_yaml_cache_is_per_configuration(tmp_path: Path) -> None: |
| 889 | + """Same content under a different configuration doesn't share the cache.""" |
| 890 | + controller = _make_controller(tmp_path) |
| 891 | + calls: list[tuple[str, str]] = [] |
| 892 | + |
| 893 | + async def _record(session: Any, configuration: str, content: str) -> dict: |
| 894 | + calls.append((configuration, content)) |
| 895 | + return {"yaml_errors": [], "validation_errors": []} |
| 896 | + |
| 897 | + controller._validate_locked = _record # type: ignore[method-assign] |
| 898 | + |
| 899 | + await controller.validate_yaml(configuration="kitchen.yaml", content="esphome:\n name: same\n") |
| 900 | + await controller.validate_yaml(configuration="bedroom.yaml", content="esphome:\n name: same\n") |
| 901 | + |
| 902 | + assert len(calls) == 2 |
| 903 | + assert {c[0] for c in calls} == {"kitchen.yaml", "bedroom.yaml"} |
| 904 | + |
| 905 | + |
| 906 | +@pytest.mark.asyncio |
| 907 | +async def test_validate_yaml_inner_lock_recheck_coalesces_concurrent_calls( |
| 908 | + tmp_path: Path, |
| 909 | +) -> None: |
| 910 | + """Two concurrent calls for identical content share a single subprocess pass.""" |
| 911 | + controller = _make_controller(tmp_path) |
| 912 | + started = asyncio.Event() |
| 913 | + release = asyncio.Event() |
| 914 | + calls = 0 |
| 915 | + |
| 916 | + async def _slow_validate(_session: Any, _configuration: str, _content: str) -> dict: |
| 917 | + nonlocal calls |
| 918 | + calls += 1 |
| 919 | + started.set() |
| 920 | + await release.wait() |
| 921 | + return {"yaml_errors": [], "validation_errors": []} |
| 922 | + |
| 923 | + controller._validate_locked = _slow_validate # type: ignore[method-assign] |
| 924 | + |
| 925 | + first = asyncio.create_task( |
| 926 | + controller.validate_yaml(configuration="kitchen.yaml", content="esphome:\n") |
| 927 | + ) |
| 928 | + # Wait for the first call to enter ``_validate_locked`` so the |
| 929 | + # second call lands in the fast-path miss + lock-wait window. |
| 930 | + await started.wait() |
| 931 | + second = asyncio.create_task( |
| 932 | + controller.validate_yaml(configuration="kitchen.yaml", content="esphome:\n") |
| 933 | + ) |
| 934 | + # Hand the second task a tick to reach the lock acquire. |
| 935 | + await asyncio.sleep(0) |
| 936 | + release.set() |
| 937 | + results = await asyncio.gather(first, second) |
| 938 | + |
| 939 | + assert calls == 1 |
| 940 | + assert results[0] == results[1] == {"yaml_errors": [], "validation_errors": []} |
0 commit comments