Skip to content

Commit

Permalink
feat(test-snapshot): while still taking snapshots of the whole state …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
sassanh committed May 21, 2024
1 parent c391fd1 commit b7e020f
Show file tree
Hide file tree
Showing 3 changed files with 44 additions and 15 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
license = "Apache-2.0"
Expand Down
48 changes: 34 additions & 14 deletions redux_pytest/fixtures/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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 = (
Expand All @@ -81,29 +91,39 @@ 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:
old_snapshot = 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',
)
assert new_snapshot == old_snapshot, f'Store snapshot mismatch - {filename}'

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()
Expand Down

0 comments on commit b7e020f

Please sign in to comment.