From 7743b157c7d6663503a5a3a05faf1efd2ac81d92 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 4 May 2026 23:38:00 -0400 Subject: [PATCH] feat(history): Implement generic HistoryManager and unit tests --- src/history.py | 65 +++++++++++++++++++++++++++++++++++++ tests/test_history.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/history.py create mode 100644 tests/test_history.py diff --git a/src/history.py b/src/history.py new file mode 100644 index 0000000..8734f33 --- /dev/null +++ b/src/history.py @@ -0,0 +1,65 @@ +import typing +import time +from dataclasses import dataclass, field + +@dataclass +class HistoryEntry: + state: typing.Any + description: str + timestamp: float = field(default_factory=lambda: time.time()) + +class HistoryManager: + def __init__(self, max_capacity: int = 100): + self.max_capacity = max_capacity + self._undo_stack: typing.List[HistoryEntry] = [] + self._redo_stack: typing.List[HistoryEntry] = [] + + def push(self, state: typing.Any, description: str) -> None: + """ + Pushes a new state to the undo stack and clears the redo stack. + If the undo stack exceeds max_capacity, the oldest state is removed. + """ + entry = HistoryEntry(state=state, description=description) + self._undo_stack.append(entry) + self._redo_stack.clear() + if len(self._undo_stack) > self.max_capacity: + self._undo_stack.pop(0) + + def undo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: + """ + Undoes the last action by moving the current_state to the redo stack + and returning the top of the undo stack. + """ + if not self._undo_stack: + return None + + redo_entry = HistoryEntry(state=current_state, description=current_description) + self._redo_stack.append(redo_entry) + return self._undo_stack.pop() + + def redo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: + """ + Redoes the last undone action by moving the current_state to the undo stack + and returning the top of the redo stack. + """ + if not self._redo_stack: + return None + + undo_entry = HistoryEntry(state=current_state, description=current_description) + self._undo_stack.append(undo_entry) + return self._redo_stack.pop() + + @property + def can_undo(self) -> bool: + return len(self._undo_stack) > 0 + + @property + def can_redo(self) -> bool: + return len(self._redo_stack) > 0 + + def get_history(self) -> typing.List[typing.Dict[str, typing.Any]]: + """Returns a list of descriptions and timestamps for the undo stack.""" + return [ + {"description": e.description, "timestamp": e.timestamp} + for e in self._undo_stack + ] diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..7120dfd --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,75 @@ +import pytest +import time +from src.history import HistoryManager, HistoryEntry + +def test_initial_state(): + hm = HistoryManager(max_capacity=5) + assert hm.can_undo is False + assert hm.can_redo is False + assert hm.get_history() == [] + +def test_push_state(): + hm = HistoryManager(max_capacity=5) + hm.push("state1", "action1") + assert hm.can_undo is True + assert hm.can_redo is False + history = hm.get_history() + assert len(history) == 1 + assert history[0]["description"] == "action1" + +def test_undo_redo(): + hm = HistoryManager(max_capacity=5) + s0 = "S0" + s1 = "S1" + s2 = "S2" + + # Start at S0, change to S1 + hm.push(s0, "Initial") + # Now at S1, change to S2 + hm.push(s1, "Action 1") + # Now at S2 + + # Undo Action 1 (go back to S1) + entry = hm.undo(s2, "Current at S2") + assert entry.state == s1 + assert entry.description == "Action 1" + assert hm.can_undo is True + assert hm.can_redo is True + + # Undo Initial (go back to S0) + entry = hm.undo(s1, "Current at S1") + assert entry.state == s0 + assert entry.description == "Initial" + assert hm.can_undo is False + assert hm.can_redo is True + + # Redo Initial (go back to S1) + entry = hm.redo(s0, "Back at S0") + assert entry.state == s1 + assert entry.description == "Current at S1" + assert hm.can_undo is True + assert hm.can_redo is True + + # Redo Action 1 (go back to S2) + entry = hm.redo(s1, "Back at S1") + assert entry.state == s2 + assert entry.description == "Current at S2" + assert hm.can_undo is True + assert hm.can_redo is False + +def test_max_capacity(): + hm = HistoryManager(max_capacity=2) + hm.push("S0", "D0") + hm.push("S1", "D1") + hm.push("S2", "D2") + assert len(hm._undo_stack) == 2 + assert hm._undo_stack[0].description == "D1" + assert hm._undo_stack[1].description == "D2" + +def test_redo_cleared_on_push(): + hm = HistoryManager(max_capacity=5) + hm.push("S0", "D0") + hm.undo("S1", "D1") + assert hm.can_redo is True + hm.push("S2", "D2") + assert hm.can_redo is False