This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
starmen1265 96f06f3751
PR Test / lint (pull_request) Successful in 2s
feat: 添加 gsd-openclaw 技能
2026-03-11 10:41:01 +08:00

509 lines
19 KiB
Python

"""
Tests for state_manager module - SessionState dataclass.
Task 1: Test state schema with dataclasses.
Task 2: Test atomic state save with backup rotation.
Task 3: Test state load with validation and fallback.
"""
import json
import os
import pytest
from pathlib import Path
from dataclasses import asdict
from gsd_openclaw.state_manager import SessionState, save_state, load_state, StateManager
class TestSessionStateCreation:
"""Test 1: SessionState can be created with all fields."""
def test_create_with_defaults(self):
"""SessionState can be created with only required fields."""
state = SessionState(
session_id="test-session-123",
project_name="test-project",
tmux_session_name="gsd-test-project",
tmux_pane_id="%0"
)
assert state.session_id == "test-session-123"
assert state.project_name == "test-project"
assert state.tmux_session_name == "gsd-test-project"
assert state.tmux_pane_id == "%0"
assert state.schema_version == "1.0"
assert state.command_count == 0
def test_create_with_all_fields(self):
"""SessionState can be created with all fields specified."""
state = SessionState(
schema_version="1.0",
session_id="test-session-456",
project_name="my-project",
tmux_session_name="gsd-my-project",
tmux_pane_id="%1",
created_at="2026-03-10T10:00:00Z",
last_active_at="2026-03-10T12:30:00Z",
command_count=42,
last_output_snapshot="Last output here...",
display_name="My Cool Project"
)
assert state.session_id == "test-session-456"
assert state.created_at == "2026-03-10T10:00:00Z"
assert state.command_count == 42
assert state.last_output_snapshot == "Last output here..."
assert state.display_name == "My Cool Project"
class TestSessionStateSerialization:
"""Test 2 & 3 & 4: SessionState serialization methods."""
def test_to_dict_returns_complete_dict(self):
"""SessionState.to_dict() returns complete dict."""
state = SessionState(
session_id="test-session-789",
project_name="test-project",
tmux_session_name="gsd-test-project",
tmux_pane_id="%2",
created_at="2026-03-10T10:00:00Z",
command_count=10
)
result = state.to_dict()
assert isinstance(result, dict)
assert result["session_id"] == "test-session-789"
assert result["project_name"] == "test-project"
assert result["tmux_session_name"] == "gsd-test-project"
assert result["tmux_pane_id"] == "%2"
assert result["created_at"] == "2026-03-10T10:00:00Z"
assert result["command_count"] == 10
assert result["schema_version"] == "1.0"
def test_from_dict_reconstructs_from_dict(self):
"""SessionState.from_dict() reconstructs from dict."""
data = {
"schema_version": "1.0",
"session_id": "test-session-abc",
"project_name": "reconstructed-project",
"tmux_session_name": "gsd-reconstructed",
"tmux_pane_id": "%3",
"created_at": "2026-03-10T08:00:00Z",
"last_active_at": "2026-03-10T09:00:00Z",
"command_count": 25,
"last_output_snapshot": "Some output",
"display_name": "Reconstructed Project"
}
state = SessionState.from_dict(data)
assert state.session_id == "test-session-abc"
assert state.project_name == "reconstructed-project"
assert state.tmux_session_name == "gsd-reconstructed"
assert state.tmux_pane_id == "%3"
assert state.created_at == "2026-03-10T08:00:00Z"
assert state.command_count == 25
assert state.last_output_snapshot == "Some output"
assert state.display_name == "Reconstructed Project"
def test_from_dict_ignores_unknown_fields(self):
"""SessionState.from_dict() ignores unknown fields in from_dict()."""
data = {
"schema_version": "1.0",
"session_id": "test-session-xyz",
"project_name": "test-project",
"tmux_session_name": "gsd-test",
"tmux_pane_id": "%4",
"unknown_field": "should be ignored",
"another_unknown": 12345
}
# Should not raise an error
state = SessionState.from_dict(data)
assert state.session_id == "test-session-xyz"
assert not hasattr(state, "unknown_field")
assert not hasattr(state, "another_unknown")
def test_to_dict_and_from_dict_roundtrip(self):
"""Verify to_dict and from_dict are inverses."""
original = SessionState(
session_id="roundtrip-test",
project_name="roundtrip-project",
tmux_session_name="gsd-roundtrip",
tmux_pane_id="%5",
created_at="2026-03-10T10:00:00Z",
last_active_at="2026-03-10T11:00:00Z",
command_count=100,
last_output_snapshot="Final output",
display_name="Roundtrip Test"
)
data = original.to_dict()
restored = SessionState.from_dict(data)
assert restored.session_id == original.session_id
assert restored.project_name == original.project_name
assert restored.tmux_session_name == original.tmux_session_name
assert restored.tmux_pane_id == original.tmux_pane_id
assert restored.created_at == original.created_at
assert restored.last_active_at == original.last_active_at
assert restored.command_count == original.command_count
assert restored.last_output_snapshot == original.last_output_snapshot
assert restored.display_name == original.display_name
class TestAtomicWrite:
"""Test 1 & 4 & 5: save_state creates file atomically."""
def test_save_state_creates_file_atomically(self, tmp_path):
"""save_state creates file atomically (temp file + rename)."""
state = SessionState(
session_id="test-atomic",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%0"
)
state_path = tmp_path / "state.json"
save_state(state, state_path)
assert state_path.exists()
# Verify content is valid JSON
with open(state_path, 'r', encoding='utf-8') as f:
data = json.load(f)
assert data["session_id"] == "test-atomic"
def test_temp_file_in_same_directory(self, tmp_path):
"""Temp file is created in same directory as target (for atomic rename)."""
state = SessionState(
session_id="test-same-dir",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%1"
)
state_path = tmp_path / "subdir" / "state.json"
save_state(state, state_path)
# After save, no temp files should remain
temp_files = list(tmp_path.rglob("*.tmp"))
assert len(temp_files) == 0
# State file should exist
assert state_path.exists()
def test_cleanup_temp_file_on_failure(self, tmp_path):
"""save_state cleans up temp file on failure."""
state = SessionState(
session_id="test-cleanup",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%2"
)
# Create a file that will cause write to fail (permission denied simulation)
state_path = tmp_path / "state.json"
# Make the directory read-only after creating a temp scenario
# This test verifies the exception handling path cleans up
# We can't easily test actual failure, but we can verify no temp files remain
save_state(state, state_path)
temp_files = list(tmp_path.glob("*.tmp"))
assert len(temp_files) == 0
class TestBackupRotation:
"""Test 2 & 3: save_state creates parent dirs and rotates backup."""
def test_save_state_creates_parent_directories(self, tmp_path):
"""save_state creates parent directories if missing."""
state = SessionState(
session_id="test-mkdir",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%3"
)
# Use nested path that doesn't exist
state_path = tmp_path / "deeply" / "nested" / "dir" / "state.json"
save_state(state, state_path)
assert state_path.exists()
assert state_path.parent.is_dir()
def test_save_state_rotates_backup(self, tmp_path):
"""save_state rotates backup (state.json -> state.json.bak)."""
state1 = SessionState(
session_id="first-save",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%4"
)
state2 = SessionState(
session_id="second-save",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%4"
)
state_path = tmp_path / "state.json"
backup_path = tmp_path / "state.json.bak"
# First save - no backup yet
save_state(state1, state_path)
assert state_path.exists()
assert not backup_path.exists()
# Second save - backup should be created
save_state(state2, state_path)
assert state_path.exists()
assert backup_path.exists()
# Backup should contain first save data
with open(backup_path, 'r', encoding='utf-8') as f:
backup_data = json.load(f)
assert backup_data["session_id"] == "first-save"
# Main file should contain second save data
with open(state_path, 'r', encoding='utf-8') as f:
main_data = json.load(f)
assert main_data["session_id"] == "second-save"
def test_save_state_overwrites_old_backup(self, tmp_path):
"""save_state overwrites old backup file."""
state1 = SessionState(session_id="v1", project_name="p", tmux_session_name="t", tmux_pane_id="%0")
state2 = SessionState(session_id="v2", project_name="p", tmux_session_name="t", tmux_pane_id="%0")
state3 = SessionState(session_id="v3", project_name="p", tmux_session_name="t", tmux_pane_id="%0")
state_path = tmp_path / "state.json"
backup_path = tmp_path / "state.json.bak"
save_state(state1, state_path)
save_state(state2, state_path)
save_state(state3, state_path)
# Backup should contain v2 (the previous state)
with open(backup_path, 'r', encoding='utf-8') as f:
backup_data = json.load(f)
assert backup_data["session_id"] == "v2"
# Main should contain v3
with open(state_path, 'r', encoding='utf-8') as f:
main_data = json.load(f)
assert main_data["session_id"] == "v3"
class TestSaveLoadState:
"""Test: save and load roundtrip."""
def test_save_load_state(self, tmp_path):
"""save_state and load_state work together."""
original = SessionState(
session_id="save-load-test",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%10",
created_at="2026-03-10T10:00:00Z",
command_count=50
)
state_path = tmp_path / "state.json"
save_state(original, state_path)
loaded = load_state(state_path)
assert loaded is not None
assert loaded.session_id == original.session_id
assert loaded.project_name == original.project_name
assert loaded.tmux_session_name == original.tmux_session_name
assert loaded.tmux_pane_id == original.tmux_pane_id
assert loaded.command_count == original.command_count
class TestStateValidation:
"""Test 3 & 4: load_state validates schema and required fields."""
def test_load_state_returns_none_for_missing_file(self, tmp_path):
"""load_state returns None for missing file."""
state_path = tmp_path / "nonexistent.json"
result = load_state(state_path)
assert result is None
def test_load_state_returns_session_state_for_valid_file(self, tmp_path):
"""load_state returns SessionState for valid file."""
state = SessionState(
session_id="valid-test",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%11"
)
state_path = tmp_path / "state.json"
save_state(state, state_path)
loaded = load_state(state_path)
assert loaded is not None
assert loaded.session_id == "valid-test"
def test_load_state_raises_value_error_for_unsupported_schema(self, tmp_path):
"""load_state raises ValueError for unsupported schema version."""
state_path = tmp_path / "state.json"
# Write a file with unsupported schema version
invalid_data = {
"schema_version": "2.0", # Unsupported
"session_id": "test",
"project_name": "test",
"tmux_session_name": "test",
"tmux_pane_id": "%0"
}
with open(state_path, 'w', encoding='utf-8') as f:
json.dump(invalid_data, f)
with pytest.raises(ValueError, match="Unsupported schema version"):
load_state(state_path)
def test_load_state_raises_value_error_for_missing_required_fields(self, tmp_path):
"""load_state raises ValueError for missing required fields."""
state_path = tmp_path / "state.json"
# Write a file with missing required field
invalid_data = {
"schema_version": "1.0",
"session_id": "test",
# Missing project_name, tmux_session_name
}
with open(state_path, 'w', encoding='utf-8') as f:
json.dump(invalid_data, f)
with pytest.raises(ValueError, match="Missing required field"):
load_state(state_path)
class TestBackupFallback:
"""Test 5 & 6: load_state falls back to backup when main file is corrupted."""
def test_load_state_falls_back_to_backup_on_json_error(self, tmp_path):
"""load_state falls back to backup when main file is corrupted (JSONDecodeError)."""
state_path = tmp_path / "state.json"
backup_path = tmp_path / "state.json.bak"
# Create a valid backup
valid_state = SessionState(
session_id="backup-valid",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%12"
)
backup_path.parent.mkdir(parents=True, exist_ok=True)
with open(backup_path, 'w', encoding='utf-8') as f:
json.dump(valid_state.to_dict(), f)
# Create corrupted main file
with open(state_path, 'w', encoding='utf-8') as f:
f.write("{ invalid json }")
# Should fall back to backup
loaded = load_state(state_path)
assert loaded is not None
assert loaded.session_id == "backup-valid"
def test_load_state_falls_back_to_backup_on_validation_error(self, tmp_path):
"""load_state falls back to backup when validation fails."""
state_path = tmp_path / "state.json"
backup_path = tmp_path / "state.json.bak"
# Create a valid backup
valid_state = SessionState(
session_id="backup-valid-2",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%13"
)
backup_path.parent.mkdir(parents=True, exist_ok=True)
with open(backup_path, 'w', encoding='utf-8') as f:
json.dump(valid_state.to_dict(), f)
# Create invalid main file (bad schema)
invalid_data = {
"schema_version": "99.0", # Invalid
"session_id": "test",
"project_name": "test",
"tmux_session_name": "test",
"tmux_pane_id": "%0"
}
with open(state_path, 'w', encoding='utf-8') as f:
json.dump(invalid_data, f)
# Should fall back to backup
loaded = load_state(state_path)
assert loaded is not None
assert loaded.session_id == "backup-valid-2"
def test_load_state_tries_backup_when_main_missing(self, tmp_path):
"""load_state tries backup when main file is missing."""
state_path = tmp_path / "state.json"
backup_path = tmp_path / "state.json.bak"
# Create a valid backup (no main file)
valid_state = SessionState(
session_id="backup-only",
project_name="test-project",
tmux_session_name="gsd-test",
tmux_pane_id="%14"
)
backup_path.parent.mkdir(parents=True, exist_ok=True)
with open(backup_path, 'w', encoding='utf-8') as f:
json.dump(valid_state.to_dict(), f)
# Should load from backup
loaded = load_state(state_path)
assert loaded is not None
assert loaded.session_id == "backup-only"
class TestStateManagerClass:
"""Test StateManager class with workspace path resolution."""
def test_get_state_path_returns_correct_path(self, tmp_path):
"""StateManager.get_state_path returns correct path for project."""
manager = StateManager("my-project", workspaces_root=tmp_path)
expected = tmp_path / "my-project" / ".gsd" / "state.json"
assert manager.get_state_path() == expected
def test_state_manager_save_persists_state(self, tmp_path):
"""StateManager.save persists state to correct location."""
manager = StateManager("test-project", workspaces_root=tmp_path)
state = SessionState(
session_id="manager-test",
project_name="test-project",
tmux_session_name="gsd-test-project",
tmux_pane_id="%20"
)
manager.save(state)
# Verify file was created at correct path
state_path = manager.get_state_path()
assert state_path.exists()
# Verify content
loaded = load_state(state_path)
assert loaded is not None
assert loaded.session_id == "manager-test"
def test_state_manager_load_retrieves_state(self, tmp_path):
"""StateManager.load retrieves state from correct location."""
manager = StateManager("load-test-project", workspaces_root=tmp_path)
state = SessionState(
session_id="load-manager-test",
project_name="load-test-project",
tmux_session_name="gsd-load-test",
tmux_pane_id="%21"
)
# Save using manager
manager.save(state)
# Load using manager
loaded = manager.load()
assert loaded is not None
assert loaded.session_id == "load-manager-test"
assert loaded.project_name == "load-test-project"
def test_state_manager_load_returns_none_when_no_state(self, tmp_path):
"""StateManager.load returns None when no state file exists."""
manager = StateManager("nonexistent-project", workspaces_root=tmp_path)
loaded = manager.load()
assert loaded is None
def test_state_manager_default_workspaces_root(self):
"""StateManager uses default ~/gsd-workspaces when not specified."""
manager = StateManager("default-project")
expected_root = Path.home() / "gsd-workspaces"
assert manager.workspaces_root == expected_root
assert manager.workspace_path == expected_root / "default-project"