feat(history): Implement generic HistoryManager and unit tests
This commit is contained in:
@@ -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
|
||||||
|
]
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user