This commit is contained in:
2025-10-13 11:34:22 +02:00
parent 2519b55f08
commit 065ab855c1
15 changed files with 427 additions and 47 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -21,6 +21,7 @@ try:
from .preset_ops import (
TEXT_TEXTURE_OT_save_preset,
TEXT_TEXTURE_OT_save_preset_enter,
TEXT_TEXTURE_OT_clear_preset_search,
TEXT_TEXTURE_OT_load_preset,
TEXT_TEXTURE_OT_delete_preset,
TEXT_TEXTURE_OT_refresh_presets,
@@ -31,6 +32,7 @@ try:
PRESET_OPERATORS = [
'TEXT_TEXTURE_OT_save_preset',
'TEXT_TEXTURE_OT_save_preset_enter',
'TEXT_TEXTURE_OT_clear_preset_search',
'TEXT_TEXTURE_OT_load_preset',
'TEXT_TEXTURE_OT_delete_preset',
'TEXT_TEXTURE_OT_refresh_presets',

View File

@@ -168,15 +168,24 @@ class TEXT_TEXTURE_OT_save_preset(Operator):
existing_preset = preset
break
target_index = None
if not existing_preset:
pass
preset = props.presets.add()
preset.name = preset_name
preset.source = 'BLEND_FILE' if props.save_with_blend_file else 'PERSISTENT_FILE'
target_index = len(props.presets) - 1
else:
pass
# Update source based on where it was saved
existing_preset.source = 'BLEND_FILE' if props.save_with_blend_file else 'PERSISTENT_FILE'
for idx, collection_preset in enumerate(props.presets):
pass
if collection_preset.name == preset_name:
pass
target_index = idx
break
# Provide clear feedback
location = "with .blend file" if props.save_with_blend_file else "to persistent storage"
@@ -188,6 +197,13 @@ class TEXT_TEXTURE_OT_save_preset(Operator):
pass
self.report({'INFO'}, f"✅ Preset '{preset_name}' saved {location}")
if target_index is not None and hasattr(props, "active_preset_index"):
pass
props.active_preset_index = target_index
if hasattr(props, "last_loaded_preset"):
pass
props.last_loaded_preset = preset_name
except (OSError, IOError, json.JSONEncodeError) as e:
pass
self.report({'ERROR'}, f"Failed to save preset: {str(e)}")
@@ -234,6 +250,22 @@ class TEXT_TEXTURE_OT_save_preset_enter(Operator):
# Delegate to the main save preset operator
return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT')
class TEXT_TEXTURE_OT_clear_preset_search(Operator):
pass
"""Clear the preset search filter"""
bl_idname = "text_texture.clear_preset_search"
bl_label = "Clear Preset Search"
bl_description = "Reset the preset search field"
bl_options = {'INTERNAL'}
def execute(self, context):
pass
props = context.scene.text_texture_props
if hasattr(props, "preset_search_query"):
pass
props.preset_search_query = ""
return {'FINISHED'}
class TEXT_TEXTURE_OT_load_preset(Operator):
pass
"""Load preset"""
@@ -382,6 +414,18 @@ class TEXT_TEXTURE_OT_load_preset(Operator):
from ..core.generation_engine import generate_preview
generate_preview(props, props.preview_size if hasattr(props, 'preview_size') else 512, props.preview_size if hasattr(props, 'preview_size') else 512)
if hasattr(props, "last_loaded_preset"):
pass
props.last_loaded_preset = self.preset_name
if hasattr(props, "active_preset_index"):
pass
for idx, preset in enumerate(props.presets):
pass
if preset.name == self.preset_name:
pass
props.active_preset_index = idx
break
source_info = {
'BLEND_FILE': "from blend file",
'PERSISTENT_FILE': "from persistent storage",
@@ -438,13 +482,27 @@ class TEXT_TEXTURE_OT_delete_preset(Operator):
self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage")
# Remove from UI list
removed_index = None
for i, preset in enumerate(props.presets):
pass
if preset.name == self.preset_name:
pass
props.presets.remove(i)
removed_index = i
break
if hasattr(props, "last_loaded_preset") and props.last_loaded_preset == self.preset_name:
pass
props.last_loaded_preset = ""
if removed_index is not None and hasattr(props, "active_preset_index"):
pass
if len(props.presets) == 0:
pass
props.active_preset_index = 0
else:
pass
props.active_preset_index = min(removed_index, len(props.presets) - 1)
delete_info = []
if blend_deleted:
pass
@@ -809,6 +867,7 @@ class TEXT_TEXTURE_OT_import_presets(Operator):
__all__ = [
'TEXT_TEXTURE_OT_save_preset',
'TEXT_TEXTURE_OT_save_preset_enter',
'TEXT_TEXTURE_OT_clear_preset_search',
'TEXT_TEXTURE_OT_load_preset',
'TEXT_TEXTURE_OT_delete_preset',
'TEXT_TEXTURE_OT_refresh_presets',

View File

@@ -607,6 +607,18 @@ class TEXT_TEXTURE_Properties(PropertyGroup):
default="New Preset"
)
preset_search_query: StringProperty(
name="Preset Search",
description="Filter presets by name",
default=""
)
last_loaded_preset: StringProperty(
name="Last Loaded Preset",
description="Tracks the most recently loaded preset name for highlighting",
default=""
)
# Property to track when user wants to save with Enter
preset_save_requested: BoolProperty(
name="Save Requested",

View File

@@ -498,78 +498,104 @@ def draw_presets_section(layout, props):
draw_feature_lock_indicator(layout, 'presets')
return
# Save preset section - use safe operator calls
save_row = layout.row(align=True)
col = layout.column(align=True)
# Save preset controls
save_row = col.row(align=True)
save_row.prop(props, "new_preset_name", text="", placeholder="Preset Name")
premium_lock = create_premium_lock_function()
safe_operator_call(save_row, "text_texture.save_preset", premium_lock,
text="Save", icon='FILE_NEW')
# Save with blend file checkbox
layout.prop(props, "save_with_blend_file", text="Project-specific (only available in this .blend file)")
save_location_row = col.row(align=True)
save_location_row.prop(props, "save_with_blend_file", text="Project-specific (saved inside this .blend)")
layout.separator()
# Import/Export and refresh buttons - use safe operator calls
backup_row = layout.row(align=True)
col.separator()
# Import/export/refresh controls
backup_row = col.row(align=True)
export_lock = create_premium_lock_function()
import_lock = create_premium_lock_function()
safe_operator_call(backup_row, "text_texture.export_presets", export_lock,
text="Export Backup", icon='EXPORT')
safe_operator_call(backup_row, "text_texture.import_presets", import_lock,
text="Import Backup", icon='IMPORT')
utility_row = layout.row(align=True)
utility_row = col.row(align=True)
refresh_lock = create_premium_lock_function()
safe_operator_call(utility_row, "text_texture.refresh_presets", refresh_lock,
text="Refresh", icon='FILE_REFRESH')
text="Refresh List", icon='FILE_REFRESH')
layout.separator()
col.separator()
# Load presets section - unified list
if props.presets:
total_presets = len(props.presets)
if total_presets:
pass
layout.label(text="Presets", icon='PRESET')
blend_count = len([preset for preset in props.presets if preset.source == 'BLEND_FILE'])
persistent_count = total_presets - blend_count
# Sort presets alphabetically for consistent display
summary_row = col.row(align=True)
summary_row.label(text=f"{total_presets} presets", icon='PRESET_HLT')
summary_row.label(text=f"{blend_count} project", icon='FILE_BLEND')
summary_row.label(text=f"{persistent_count} global", icon='FILE_CACHE')
search_row = col.row(align=True)
search_row.prop(props, "preset_search_query", text="", icon='VIEWZOOM')
if getattr(props, "preset_search_query", "").strip():
search_row.operator("text_texture.clear_preset_search", text="", icon='PANEL_CLOSE')
# Prepare sorted and filtered presets for display
search_query = getattr(props, "preset_search_query", "").strip().lower()
sorted_presets = sorted(props.presets, key=lambda p: p.name.lower())
if search_query:
visible_presets = [preset for preset in sorted_presets if search_query in preset.name.lower()]
else:
visible_presets = sorted_presets
for preset in sorted_presets:
if visible_presets:
pass
row = layout.row(align=True)
row.scale_y = 0.9
for preset in visible_presets:
pass
row = col.row(align=True)
row.scale_y = 0.95
is_active = getattr(props, "last_loaded_preset", "") == preset.name
load_icon = 'CHECKMARK' if is_active else 'PRESET'
# Load button - use safe operator call
load_lock = create_premium_lock_function()
load_btn = safe_operator_call(row, "text_texture.load_preset", load_lock,
text=preset.name)
text=preset.name, icon=load_icon)
if load_btn:
load_btn.preset_name = preset.name
# Quick overwrite button - use safe operator call
source_icon = 'FILE_BLEND' if preset.source == 'BLEND_FILE' else 'FILE_CACHE'
source_label = "Project" if preset.source == 'BLEND_FILE' else "Global"
row.label(text=source_label, icon=source_icon)
actions = row.row(align=True)
actions.alignment = 'RIGHT'
overwrite_lock = create_premium_lock_function()
overwrite_btn = safe_operator_call(row, "text_texture.save_preset", overwrite_lock,
overwrite_btn = safe_operator_call(actions, "text_texture.save_preset", overwrite_lock,
text="", icon='FILE_REFRESH')
if overwrite_btn:
overwrite_btn.overwrite = True
overwrite_btn.preset_name = preset.name
# Delete button - use safe operator call
delete_lock = create_premium_lock_function()
delete_btn = safe_operator_call(row, "text_texture.delete_preset", delete_lock,
delete_btn = safe_operator_call(actions, "text_texture.delete_preset", delete_lock,
text="", icon='TRASH')
if delete_btn:
delete_btn.preset_name = preset.name
else:
pass
layout.label(text="No presets yet")
layout.label(text="Save your settings above to create your first preset.")
col.label(text="No presets match your search", icon='INFO')
else:
pass
col.label(text="No presets yet", icon='INFO')
col.label(text="Save your current settings to start your preset library.")
def draw_shader_section(layout, props):
pass

View File

@@ -0,0 +1,17 @@
from unittest.mock import Mock
from src.operators.preset_ops import TEXT_TEXTURE_OT_clear_preset_search
def test_clear_preset_search_resets_query():
props = Mock()
props.preset_search_query = "Glow"
context = Mock()
context.scene.text_texture_props = props
op = TEXT_TEXTURE_OT_clear_preset_search()
result = op.execute(context)
assert result == {'FINISHED'}
assert props.preset_search_query == ""

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,263 @@
import json
import os
from pathlib import Path
from unittest.mock import Mock
import pytest
import src.presets.storage_system as storage_system
class MockPresetItem:
"""Simple container for preset collection entries."""
def __init__(self):
self.name = ""
self.source = ""
class MockPresetCollection:
"""Minimal stand-in for Blender's CollectionProperty."""
def __init__(self):
self._items = []
self.cleared = False
def clear(self):
self.cleared = True
self._items.clear()
def add(self):
item = MockPresetItem()
self._items.append(item)
return item
def remove(self, index):
self._items.pop(index)
def __iter__(self):
return iter(self._items)
def __len__(self):
return len(self._items)
class MockProps:
"""Mock text texture properties containing preset collection."""
def __init__(self):
self.presets = MockPresetCollection()
self.save_with_blend_file = False
self.new_preset_name = ""
self.active_preset_index = 0
self.preset_search_query = ""
self.last_loaded_preset = ""
class MockScene(dict):
"""Dictionary-like scene object supporting custom properties."""
def __init__(self, props=None):
super().__init__()
self.text_texture_props = props
def __bool__(self):
# Scene should be truthy even when no custom properties are set
return True
@pytest.fixture
def make_scene(mock_bpy_fixture):
"""Factory for assigning a mock scene to bpy.context."""
def _make_scene(props=None):
scene = MockScene(props)
mock_bpy_fixture.context.scene = scene
return scene
return _make_scene
def test_get_persistent_preset_path_creates_directory(tmp_path, mock_bpy_fixture):
config_dir = tmp_path / "config"
config_dir.mkdir()
mock_bpy_fixture.utils.user_resource.return_value = str(config_dir)
path = storage_system.get_persistent_preset_path()
expected = config_dir / "text_texture_generator" / "presets"
assert Path(path) == expected
assert expected.is_dir()
def test_save_preset_unified_persistent_writes_file(tmp_path, monkeypatch):
persistent_dir = tmp_path / "persistent"
persistent_dir.mkdir()
monkeypatch.setattr(storage_system, "get_persistent_preset_path", lambda: str(persistent_dir))
preset_data = {"text": "hello", "font_size": 24}
result = storage_system.save_preset_unified(preset_data, "SamplePreset", save_to_blend_file=False)
saved_path = persistent_dir / "SamplePreset.json"
assert result is True
assert saved_path.exists()
with saved_path.open("r") as handle:
assert json.load(handle) == preset_data
def test_save_preset_unified_blend_delegates(monkeypatch):
mock_save = Mock(return_value=True)
monkeypatch.setattr(storage_system, "save_blend_file_preset", mock_save)
payload = {"text": "blend storage"}
result = storage_system.save_preset_unified(payload, "BlendPreset", save_to_blend_file=True)
assert result is True
mock_save.assert_called_once_with("BlendPreset", payload)
def test_get_blend_file_presets_initializes_empty_collection(make_scene):
scene = make_scene()
presets = storage_system.get_blend_file_presets()
assert presets == {}
assert scene["text_texture_presets"] == "{}"
def test_save_blend_file_preset_stores_json_in_scene(make_scene):
scene = make_scene()
preset_content = {"text": "stored", "font_size": 18}
result = storage_system.save_blend_file_preset("StoredPreset", preset_content)
assert result is True
stored = json.loads(scene["text_texture_presets"])
assert stored["StoredPreset"] == preset_content
roundtrip = storage_system.get_blend_file_presets()
assert roundtrip["StoredPreset"] == preset_content
def test_delete_blend_file_preset_removes_entry(make_scene):
scene = make_scene()
storage_system.save_blend_file_preset("TempPreset", {"value": 1})
removed = storage_system.delete_blend_file_preset("TempPreset")
assert removed is True
assert json.loads(scene["text_texture_presets"]) == {}
removed_again = storage_system.delete_blend_file_preset("TempPreset")
assert removed_again is False
def test_get_unified_preset_list_prioritizes_blend_presets(tmp_path, monkeypatch):
persistent_dir = tmp_path / "persistent"
persistent_dir.mkdir()
monkeypatch.setattr(storage_system, "get_persistent_preset_path", lambda: str(persistent_dir))
with (persistent_dir / "alpha.json").open("w") as handle:
json.dump({"source": "persistent"}, handle)
with (persistent_dir / "shared.json").open("w") as handle:
json.dump({"source": "persistent-shared"}, handle)
monkeypatch.setattr(
storage_system,
"get_blend_file_presets",
lambda: {
"shared": {"source": "blend"},
"blend_only": {"source": "blend-only"},
},
)
presets = storage_system.get_unified_preset_list()
assert presets["alpha"][1] == "PERSISTENT_FILE"
assert presets["alpha"][0]["source"] == "persistent"
assert presets["shared"][1] == "BLEND_FILE"
assert presets["shared"][0]["source"] == "blend"
assert presets["blend_only"][1] == "BLEND_FILE"
def test_migrate_from_legacy_system_migrates_files_and_creates_backup(tmp_path, monkeypatch):
legacy_dir = tmp_path / "legacy"
persistent_dir = tmp_path / "persistent"
legacy_dir.mkdir()
persistent_dir.mkdir()
legacy_payload = {"example": True}
with (legacy_dir / "legacy_preset.json").open("w") as handle:
json.dump(legacy_payload, handle)
with (persistent_dir / "existing.json").open("w") as handle:
json.dump({"existing": True}, handle)
monkeypatch.setattr(storage_system, "get_preset_path", lambda: str(legacy_dir))
monkeypatch.setattr(storage_system, "get_persistent_preset_path", lambda: str(persistent_dir))
result = storage_system.migrate_from_legacy_system()
migrated_file = persistent_dir / "legacy_preset.json"
backup_file = persistent_dir / "legacy_backup" / "legacy_preset.json"
marker_file = persistent_dir / ".migration_complete"
assert result is True
assert migrated_file.exists()
assert json.loads(migrated_file.read_text()) == legacy_payload
assert backup_file.exists()
assert marker_file.exists()
def test_initialize_presets_populates_props_collection(make_scene, monkeypatch):
props = MockProps()
# Seed with existing entry to ensure clear() is invoked
props.presets.add().name = "stale"
migrate_mock = Mock(return_value=True)
monkeypatch.setattr(storage_system, "migrate_from_legacy_system", migrate_mock)
monkeypatch.setattr(
storage_system,
"get_unified_preset_list",
lambda: {
"BlendPreset": ({"value": "blend"}, "BLEND_FILE"),
"PersistentPreset": ({"value": "persistent"}, "PERSISTENT_FILE"),
},
)
scene = make_scene(props)
storage_system.initialize_presets()
assert migrate_mock.called
assert props.presets.cleared is True
assert len(props.presets) == 2
entries = {(item.name, item.source) for item in props.presets}
assert ("BlendPreset", "BLEND_FILE") in entries
assert ("PersistentPreset", "PERSISTENT_FILE") in entries
assert "text_texture_presets" not in scene # ensure scene custom data untouched
def test_ensure_presets_available_refreshes_when_empty(make_scene, monkeypatch):
props = MockProps()
make_scene(props)
refresh_mock = Mock(return_value=True)
monkeypatch.setattr(storage_system, "refresh_presets_sync", refresh_mock)
result = storage_system.ensure_presets_available()
assert result is True
refresh_mock.assert_called_once()
def test_ensure_presets_available_returns_true_when_presets_loaded(make_scene, monkeypatch):
props = MockProps()
props.presets.add().name = "cached"
make_scene(props)
refresh_mock = Mock(return_value=True)
monkeypatch.setattr(storage_system, "refresh_presets_sync", refresh_mock)
result = storage_system.ensure_presets_available()
assert result is True
refresh_mock.assert_not_called()
def test_refresh_presets_sync_returns_false_without_scene(mock_bpy_fixture):
mock_bpy_fixture.context.scene = None
assert storage_system.refresh_presets_sync() is False