Files
by-aeandCursor Agent 3a1dcd649d fix: support Unicode names and prevent concurrent write corruption in store
- Allow Unicode letters/numbers in preset names (e.g. 角色, Персонаж, Émilie)
- Add thread-safe locking per bucket to prevent race conditions during concurrent writes
- Use mkstemp() for atomic temp file creation to avoid tmp file name collisions
- Add tests demonstrating the bugs and verifying the fixes

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-24 20:52:01 +00:00

217 lines
8.0 KiB
Python

"""Unit tests for the store module."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Mock folder_paths before importing store
sys.modules['folder_paths'] = MagicMock()
from store import _validate_name, load_bucket, save_entry, delete_entry
class TestStore(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.patcher = patch('store.folder_paths')
self.mock_folder_paths = self.patcher.start()
self.mock_folder_paths.get_user_directory.return_value = self.temp_dir
def tearDown(self):
self.patcher.stop()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_validate_name_valid(self):
self.assertEqual(_validate_name("MyCharacter"), "MyCharacter")
self.assertEqual(_validate_name("char_123"), "char_123")
self.assertEqual(_validate_name("test-name"), "test-name")
self.assertEqual(_validate_name("name with spaces"), "name with spaces")
def test_validate_name_invalid(self):
with self.assertRaises(ValueError):
_validate_name("")
with self.assertRaises(ValueError):
_validate_name(" ")
with self.assertRaises(ValueError):
_validate_name("-starts-with-dash")
with self.assertRaises(ValueError):
_validate_name("has/slash")
with self.assertRaises(ValueError):
_validate_name("has\\backslash")
with self.assertRaises(ValueError):
_validate_name("a" * 65)
def test_save_and_load_entry(self):
test_data = {"desc": "A test character", "palette": ["#FF0000", "#00FF00"]}
save_entry("characters", "TestChar", test_data)
bucket = load_bucket("characters")
self.assertIn("TestChar", bucket)
self.assertEqual(bucket["TestChar"], test_data)
def test_save_multiple_entries(self):
save_entry("characters", "Char1", {"data": 1})
save_entry("characters", "Char2", {"data": 2})
bucket = load_bucket("characters")
self.assertEqual(len(bucket), 2)
self.assertEqual(bucket["Char1"]["data"], 1)
self.assertEqual(bucket["Char2"]["data"], 2)
def test_overwrite_entry(self):
save_entry("objects", "Object1", {"version": 1})
save_entry("objects", "Object1", {"version": 2})
bucket = load_bucket("objects")
self.assertEqual(bucket["Object1"]["version"], 2)
def test_delete_entry(self):
save_entry("scenes", "Scene1", {"data": "test"})
self.assertTrue(delete_entry("scenes", "Scene1"))
bucket = load_bucket("scenes")
self.assertNotIn("Scene1", bucket)
def test_delete_nonexistent_entry(self):
self.assertFalse(delete_entry("characters", "NonExistent"))
def test_load_empty_bucket(self):
bucket = load_bucket("characters")
self.assertEqual(bucket, {})
def test_invalid_bucket(self):
with self.assertRaises(ValueError):
load_bucket("invalid_bucket")
with self.assertRaises(ValueError):
save_entry("invalid_bucket", "name", {})
with self.assertRaises(ValueError):
delete_entry("invalid_bucket", "name")
def test_persistence_across_loads(self):
save_entry("objects", "Persistent", {"value": "data"})
first_load = load_bucket("objects")
second_load = load_bucket("objects")
self.assertEqual(first_load["Persistent"], second_load["Persistent"])
def test_corrupt_json_creates_backup_and_returns_empty(self):
# Create a corrupt JSON file
bucket_path = os.path.join(self.temp_dir, "byae-ideogram-tools", "characters.json")
os.makedirs(os.path.dirname(bucket_path), exist_ok=True)
with open(bucket_path, "w") as f:
f.write("{invalid json content")
# Loading should return empty dict and create backup
bucket = load_bucket("characters")
self.assertEqual(bucket, {})
# Check that backup file was created
backup_files = [f for f in os.listdir(os.path.dirname(bucket_path))
if f.startswith("characters.json.corrupt-")]
self.assertEqual(len(backup_files), 1)
# Verify backup contains original corrupt content
backup_path = os.path.join(os.path.dirname(bucket_path), backup_files[0])
with open(backup_path, "r") as f:
self.assertEqual(f.read(), "{invalid json content")
# Verify original file no longer exists
self.assertFalse(os.path.exists(bucket_path))
def test_corrupt_file_does_not_affect_other_buckets(self):
# Save valid entry in objects bucket
save_entry("objects", "ValidObject", {"data": "test"})
# Create corrupt characters file
bucket_path = os.path.join(self.temp_dir, "byae-ideogram-tools", "characters.json")
os.makedirs(os.path.dirname(bucket_path), exist_ok=True)
with open(bucket_path, "w") as f:
f.write("corrupted")
# Load all should still work, with characters empty but objects intact
from store import load_all
stores = load_all()
self.assertEqual(stores["characters"], {})
self.assertIn("ValidObject", stores["objects"])
self.assertEqual(stores["objects"]["ValidObject"]["data"], "test")
def test_save_works_after_corrupt_file_recovery(self):
# Create corrupt file
bucket_path = os.path.join(self.temp_dir, "byae-ideogram-tools", "scenes.json")
os.makedirs(os.path.dirname(bucket_path), exist_ok=True)
with open(bucket_path, "w") as f:
f.write("[not a dict]")
# Load should recover
load_bucket("scenes")
# Save should now work
save_entry("scenes", "NewScene", {"data": "value"})
# Verify save succeeded
bucket = load_bucket("scenes")
self.assertIn("NewScene", bucket)
self.assertEqual(bucket["NewScene"]["data"], "value")
def test_unicode_character_names(self):
# Test that Unicode names work correctly
save_entry("characters", "角色123", {"desc": "Japanese character"})
save_entry("characters", "Персонаж", {"desc": "Russian character"})
save_entry("characters", "Émilie", {"desc": "French character"})
bucket = load_bucket("characters")
self.assertIn("角色123", bucket)
self.assertIn("Персонаж", bucket)
self.assertIn("Émilie", bucket)
self.assertEqual(bucket["角色123"]["desc"], "Japanese character")
self.assertEqual(bucket["Персонаж"]["desc"], "Russian character")
self.assertEqual(bucket["Émilie"]["desc"], "French character")
def test_concurrent_write_safety(self):
# Test that concurrent writes don't corrupt the file
import threading
import time
errors = []
def writer(name, value):
try:
for i in range(10):
save_entry("characters", name, {"iteration": i, "value": value})
time.sleep(0.001)
except Exception as e:
errors.append((name, str(e)))
threads = [
threading.Thread(target=writer, args=(f"char{i}", i))
for i in range(5)
]
for t in threads:
t.start()
for t in threads:
t.join()
# Check no errors occurred
self.assertEqual(errors, [], f"Concurrent write errors: {errors}")
# Verify all characters were saved
bucket = load_bucket("characters")
for i in range(5):
self.assertIn(f"char{i}", bucket)
self.assertIn("iteration", bucket[f"char{i}"])
self.assertEqual(bucket[f"char{i}"]["value"], i)
if __name__ == "__main__":
unittest.main()