From b7e020fc8ae6bca1e03c2f4c6e28970912acdc51 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Wed, 22 May 2024 02:22:43 +0400 Subject: [PATCH] feat(test-snapshot): while still taking snapshots of the whole state of the store, one can narrow this down by providing a selector to the `snapshot` method (used to be a property) feat(test-snapshot): new `monitor` method to let a test automatically take snapshots of the store whenever it is changed. Takes an optional selector to narrow down the snapshot. --- CHANGELOG.md | 9 ++++++ pyproject.toml | 2 +- redux_pytest/fixtures/snapshot.py | 48 ++++++++++++++++++++++--------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c9507..4d745f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Version 0.15.5 + +- feat(test-snapshot): while still taking snapshots of the whole state of the + store, one can narrow this down by providing a selector to the `snapshot` method + (used to be a property) +- feat(test-snapshot): new `monitor` method to let a test automatically take snapshots + of the store whenever it is changed. Takes an optional selector to narrow down + the snapshot. + ## Version 0.15.4 - build(pypi): add metadata diff --git a/pyproject.toml b/pyproject.toml index 5e7c7d3..2eb478e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-redux" -version = "0.15.4" +version = "0.15.5" description = "Redux implementation for Python" authors = ["Sassan Haradji "] license = "Apache-2.0" diff --git a/redux_pytest/fixtures/snapshot.py b/redux_pytest/fixtures/snapshot.py index 8cc1d06..518c75c 100644 --- a/redux_pytest/fixtures/snapshot.py +++ b/redux_pytest/fixtures/snapshot.py @@ -6,21 +6,20 @@ import json import os from collections import defaultdict -from typing import TYPE_CHECKING, Any, cast +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Generic, cast import pytest -from redux.basic_types import FinishEvent +from redux.basic_types import FinishEvent, State if TYPE_CHECKING: - from pathlib import Path - from _pytest.fixtures import SubRequest from redux.main import Store -class StoreSnapshot: +class StoreSnapshot(Generic[State]): """Context object for tests taking snapshots of the store.""" def __init__( @@ -32,11 +31,14 @@ def __init__( store: Store, ) -> None: """Create a new store snapshot context.""" + self._is_failed = False self._is_closed = False self.override = override self.test_counter: dict[str | None, int] = defaultdict(int) file = path.with_suffix('').name - self.results_dir = path.parent / 'results' / file / test_id.split('::')[-1][5:] + self.results_dir = Path( + path.parent / 'results' / file / test_id.split('::')[-1][5:], + ) if self.results_dir.exists(): for file in self.results_dir.glob( 'store-*.jsonc' if override else 'store-*.mismatch.jsonc', @@ -47,12 +49,15 @@ def __init__( self.store = store store.subscribe_event(FinishEvent, self.close) - @property - def json_snapshot(self: StoreSnapshot) -> str: + def json_snapshot( + self: StoreSnapshot[State], + *, + selector: Callable[[State], Any] = lambda state: state, + ) -> str: """Return the snapshot of the current state of the store.""" return ( json.dumps( - self.store.snapshot, + self.store.serialize_value(selector(self.store._state)), # noqa: SLF001 indent=2, sort_keys=True, ensure_ascii=False, @@ -61,13 +66,18 @@ def json_snapshot(self: StoreSnapshot) -> str: else '' ) - def get_filename(self: StoreSnapshot, title: str | None) -> str: + def get_filename(self: StoreSnapshot[State], title: str | None) -> str: """Get the filename for the snapshot.""" if title: return f"""store-{title}-{self.test_counter[title]:03d}""" return f"""store-{self.test_counter[title]:03d}""" - def take(self: StoreSnapshot, *, title: str | None = None) -> None: + def take( + self: StoreSnapshot[State], + *, + title: str | None = None, + selector: Callable[[State], Any] = lambda state: state, + ) -> None: """Take a snapshot of the current window.""" if self._is_closed: msg = ( @@ -81,7 +91,7 @@ def take(self: StoreSnapshot, *, title: str | None = None) -> None: json_path = path.with_suffix('.jsonc') mismatch_path = path.with_suffix('.mismatch.jsonc') - new_snapshot = self.json_snapshot + new_snapshot = self.json_snapshot(selector=selector) if self.override: json_path.write_text(f'// {filename}\n{new_snapshot}\n') # pragma: no cover else: @@ -89,6 +99,7 @@ def take(self: StoreSnapshot, *, title: str | None = None) -> None: if json_path.exists(): old_snapshot = json_path.read_text().split('\n', 1)[1][:-1] if old_snapshot != new_snapshot: + self._is_failed = True mismatch_path.write_text( # pragma: no cover f'// MISMATCH: {filename}\n{new_snapshot}\n', ) @@ -96,14 +107,23 @@ def take(self: StoreSnapshot, *, title: str | None = None) -> None: self.test_counter[title] += 1 - def close(self: StoreSnapshot) -> None: + def monitor(self: StoreSnapshot[State], selector: Callable[[State], Any]) -> None: + """Monitor the state of the store and take snapshots.""" + + @self.store.autorun(selector=selector) + def _(state: State) -> None: + self.take(selector=lambda _: state) + + def close(self: StoreSnapshot[State]) -> None: """Close the snapshot context.""" + self._is_closed = True + if self._is_failed: + return for title in self.test_counter: filename = self.get_filename(title) json_path = (self.results_dir / filename).with_suffix('.jsonc') assert not json_path.exists(), f'Snapshot {filename} not taken' - self._is_closed = True @pytest.fixture()