Major features:
- Autonomous scene direction via director agent (replaces auto-direct)
- Inline image display in scene feed
- Character visuals tab for portrait/cover image management
- Character message avatars with dynamic portrait selection
- Pocket TTS and llama.cpp client support
- Message appearance overhaul with configurable markdown display

Improvements:
- KoboldCpp: adaptive-p, min-p, presence/frequency penalty support
- Setup wizard for initial configuration
- Director chat action toggles
- Visual agent: resolution presets, prompt revision, auto-analysis
- Experimental concurrent requests for hosted LLM clients
- Node editor alignment shortcuts (X/Y) and color picker

Bugfixes:
- Empty response retry loop
- Client system prompt display
- Character detail pins loading
- ComfyUI workflow charset encoding
- Various layout and state issues

Breaking: Removed INSTRUCTOR embeddings
This commit is contained in:
veguAI
2026-01-27 10:22:41 +02:00
committed by GitHub
parent 20af2a9f4b
commit d0ebe95ca6
493 changed files with 50656 additions and 10683 deletions

View File

@@ -20,7 +20,13 @@
"reference": [
"SCENE_BACKGROUND"
],
"analysis": null
"analysis": null,
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
},
"TEST_001_003": "SCENE_BACKGROUND",
"TEST_001_004": {
@@ -44,7 +50,13 @@
"reference_assets": [],
"tags": [],
"reference": [],
"analysis": ""
"analysis": "",
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
},
"TEST_002_003": {
"format": "SQUARE",
@@ -63,18 +75,22 @@
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2"
],
"TEST_004_002": 5,
"TEST_005_003": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d",
"9d61cbb3ad289f1f8cf1eb69ee436a12416386cbede74f665357c2e58cd4d2a3"
],
"TEST_005_003b": 3,
"TEST_005_004": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d",
"9d61cbb3ad289f1f8cf1eb69ee436a12416386cbede74f665357c2e58cd4d2a3"
],
"TEST_005_004b": 3,
"TEST_005_003": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d",
"9d61cbb3ad289f1f8cf1eb69ee436a12416386cbede74f665357c2e58cd4d2a3"
],
"TEST_005_003b": 3,
"TEST_005_001": [
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2"
],
"TEST_005_001b": 1,
"TEST_005_002": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d",
@@ -83,32 +99,23 @@
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2"
],
"TEST_005_002b": 5,
"TEST_005_001": [
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2"
"TEST_005_007": [
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d"
],
"TEST_005_001b": 1,
"TEST_005_007b": 1,
"TEST_005_005": [
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d"
],
"TEST_005_005b": 1,
"TEST_005_006": [
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d"
],
"TEST_005_006b": 1,
"TEST_005_008": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"9d61cbb3ad289f1f8cf1eb69ee436a12416386cbede74f665357c2e58cd4d2a3"
],
"TEST_005_008b": 2,
"TEST_005_007": [
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d"
],
"TEST_005_007b": 1,
"TEST_005_006": [
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d"
],
"TEST_005_006b": 1,
"TEST_005_009": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"4928ce9046c66de3189b47f5bbcd3cafc92f37373845787c1eb64d08b29f4a1e"
],
"TEST_005_009b": 2,
"TEST_005_011": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"4928ce9046c66de3189b47f5bbcd3cafc92f37373845787c1eb64d08b29f4a1e"
@@ -117,5 +124,23 @@
"TEST_005_010": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802"
],
"TEST_005_010b": 1
"TEST_005_010b": 1,
"TEST_005_009": [
"3da1ad9438d0d6b4bd1402ea60d2a53803bb4c69ffdaa84f598a8fa14d754802",
"4928ce9046c66de3189b47f5bbcd3cafc92f37373845787c1eb64d08b29f4a1e"
],
"TEST_005_009b": 2,
"TEST_006_001": true,
"TEST_006_002": [
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2"
],
"TEST_006_004": [],
"TEST_006_003": [
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2"
],
"TEST_006_006": [
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d",
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2",
"4928ce9046c66de3189b47f5bbcd3cafc92f37373845787c1eb64d08b29f4a1e"
]
}

View File

@@ -4,11 +4,22 @@
"test_002_001": true,
"test_002_002": true,
"test_002_003": true,
"test_003_002": true,
"test_003_001": true,
"test_003_004": true,
"test_003_002": true,
"test_003_003": true,
"test_003_004": true,
"test_004_001": true,
"test_004_002": true,
"test_004_003": true,
"test_004_001": true
"test_005_001": 100.0,
"test_005_002": 100.0,
"test_005_003": 100.0,
"test_005_004": null,
"test_005_005": 100.0,
"test_005_006": {
"b": {
"d": 100.0
}
},
"test_005_007": null
}

View File

@@ -68,6 +68,7 @@
"negative_prompt": "",
"reference_assets": [],
"tags": [],
"analysis": "",
"reference": false,
"format": "PORTRAIT"
},
@@ -318,12 +319,13 @@
"title": "Asset Exists",
"id": "3653c4ac-0c7e-4843-80f7-e30256d638fa",
"properties": {
"asset_id": ""
"asset_id": "",
"return_bool": false
},
"x": 960,
"y": 1980,
"width": 210,
"height": 78,
"height": 102,
"collapsed": false,
"inherited": false,
"registry": "assets/AssetExists",
@@ -903,7 +905,7 @@
"x": 1120,
"y": 290,
"width": 178,
"height": 286,
"height": 306,
"collapsed": false,
"inherited": false,
"registry": "assets/UnpackAssetMeta",
@@ -940,7 +942,7 @@
},
"x": 1010,
"y": 750,
"width": 210,
"width": 296,
"height": 138,
"collapsed": false,
"inherited": false,
@@ -1012,7 +1014,7 @@
"x": 1690,
"y": 1070,
"width": 178,
"height": 286,
"height": 306,
"collapsed": false,
"inherited": false,
"registry": "assets/UnpackAssetMeta",
@@ -1069,22 +1071,6 @@
"registry": "state/SetState",
"base_type": "core/Node"
},
"80b727a6-4cc0-4b5d-a483-c4ce475dc07a": {
"title": "SET shared.TEST_005_009",
"id": "80b727a6-4cc0-4b5d-a483-c4ce475dc07a",
"properties": {
"name": "TEST_005_009",
"scope": "shared"
},
"x": 1020,
"y": 6320,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"ee494b62-d657-4a88-afe8-cb3189ad865e": {
"title": "Search Assets",
"id": "ee494b62-d657-4a88-afe8-cb3189ad865e",
@@ -1107,28 +1093,6 @@
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"f479210d-a8a9-4124-af30-887c1a2656fa": {
"title": "Search Assets",
"id": "f479210d-a8a9-4124-af30-887c1a2656fa",
"properties": {
"vis_type": "ANY",
"character_name": "",
"tags": [],
"reference_vis_types": [
"SCENE_BACKGROUND"
],
"tag_match_mode": "all",
"references_only": false
},
"x": 569,
"y": 6316,
"width": 329,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"dedaaa14-aeee-4bcf-984c-dfcc64570b9d": {
"title": "SET shared.TEST_005_010b",
"id": "dedaaa14-aeee-4bcf-984c-dfcc64570b9d",
@@ -1237,6 +1201,392 @@
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"f479210d-a8a9-4124-af30-887c1a2656fa": {
"title": "Search Assets",
"id": "f479210d-a8a9-4124-af30-887c1a2656fa",
"properties": {
"vis_type": "ANY",
"character_name": "",
"tags": [],
"reference_vis_types": [
"SCENE_BACKGROUND"
],
"tag_match_mode": "all",
"references_only": false
},
"x": 569,
"y": 6316,
"width": 329,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"80b727a6-4cc0-4b5d-a483-c4ce475dc07a": {
"title": "SET shared.TEST_005_009",
"id": "80b727a6-4cc0-4b5d-a483-c4ce475dc07a",
"properties": {
"name": "TEST_005_009",
"scope": "shared"
},
"x": 1030,
"y": 6290,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"a2faf53d-4a55-4595-9ce9-ccc48adb6013": {
"title": "Search Assets",
"id": "a2faf53d-4a55-4595-9ce9-ccc48adb6013",
"properties": {
"vis_type": "ANY",
"character_name": "",
"tags": [],
"reference_vis_types": [],
"tag_match_mode": "all"
},
"x": 550,
"y": 7860,
"width": 329,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"12d0d3d3-e283-442c-908a-7bd79a03f39b": {
"title": "Compare",
"id": "12d0d3d3-e283-442c-908a-7bd79a03f39b",
"properties": {
"operation": "equals",
"tolerance": 0.0001,
"a": 0,
"b": 5
},
"x": 940,
"y": 7870,
"width": 210,
"height": 150,
"collapsed": false,
"inherited": false,
"registry": "data/number/Compare",
"base_type": "core/Node"
},
"7e93915f-f938-4d82-ae31-7e35a8039ed1": {
"title": "SET shared.TEST_006_001",
"id": "7e93915f-f938-4d82-ae31-7e35a8039ed1",
"properties": {
"name": "TEST_006_001",
"scope": "shared"
},
"x": 1190,
"y": 7860,
"width": 230,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"ace5e558-56d7-4b87-a188-850de13b972c": {
"title": "Stage 8",
"id": "ace5e558-56d7-4b87-a188-850de13b972c",
"properties": {
"stage": 8
},
"x": 1470,
"y": 7870,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"bdbaaa05-0cc2-4d93-b675-f0d491c32369": {
"title": "Search Assets",
"id": "bdbaaa05-0cc2-4d93-b675-f0d491c32369",
"properties": {
"vis_type": "ANY",
"character_name": "",
"tags": [],
"reference_vis_types": [],
"tag_match_mode": "all"
},
"x": 552,
"y": 8262,
"width": 329,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"139afdbe-bb44-43d0-93f3-3c5d9650929f": {
"title": "Select Assets",
"id": "139afdbe-bb44-43d0-93f3-3c5d9650929f",
"properties": {
"mode": "noop",
"vis_types": [
"CHARACTER_CARD"
],
"reference_vis_types": []
},
"x": 950,
"y": 8280,
"width": 312,
"height": 166,
"collapsed": false,
"inherited": false,
"registry": "assets/SelectAssets",
"base_type": "core/Node"
},
"e6d2dbe5-cb93-4643-b5eb-6626023131a4": {
"title": "SET shared.TEST_006_002",
"id": "e6d2dbe5-cb93-4643-b5eb-6626023131a4",
"properties": {
"name": "TEST_006_002",
"scope": "shared"
},
"x": 1330,
"y": 8280,
"width": 230,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"a5653cab-96e7-4713-927e-f54cde3c98a7": {
"title": "Stage 9",
"id": "a5653cab-96e7-4713-927e-f54cde3c98a7",
"properties": {
"stage": 9
},
"x": 1620,
"y": 8280,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"5d98a7fb-ea97-4807-acf9-20b80bab97cb": {
"title": "Search Assets",
"id": "5d98a7fb-ea97-4807-acf9-20b80bab97cb",
"properties": {
"vis_type": "ANY",
"character_name": "",
"tags": [],
"reference_vis_types": [],
"tag_match_mode": "all"
},
"x": 552,
"y": 8667,
"width": 329,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"09a732cc-d6d0-474e-b584-bc17a7188ccc": {
"title": "Select Assets",
"id": "09a732cc-d6d0-474e-b584-bc17a7188ccc",
"properties": {
"mode": "noop",
"vis_types": [
"SCENE_CARD"
],
"reference_vis_types": []
},
"x": 950,
"y": 8920,
"width": 312,
"height": 166,
"collapsed": false,
"inherited": false,
"registry": "assets/SelectAssets",
"base_type": "core/Node"
},
"ca08fd07-3207-4c61-981b-0731e60cd299": {
"title": "Select Assets",
"id": "ca08fd07-3207-4c61-981b-0731e60cd299",
"properties": {
"mode": "noop",
"vis_types": [
"CHARACTER_CARD"
],
"reference_vis_types": []
},
"x": 949,
"y": 8685,
"width": 312,
"height": 166,
"collapsed": false,
"inherited": false,
"registry": "assets/SelectAssets",
"base_type": "core/Node"
},
"9f1ba794-09d2-4738-9dbf-3d63ae20e4ff": {
"title": "Stage 10",
"id": "9f1ba794-09d2-4738-9dbf-3d63ae20e4ff",
"properties": {
"stage": 10
},
"x": 2030,
"y": 8900,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"5f46cf7a-bb80-418b-aa57-b86d7e626669": {
"title": "SET shared.TEST_006_003",
"id": "5f46cf7a-bb80-418b-aa57-b86d7e626669",
"properties": {
"name": "TEST_006_003",
"scope": "shared"
},
"x": 1690,
"y": 8710,
"width": 230,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"6fe9ec26-0bc5-4d81-9b3e-84992bf7aebd": {
"title": "SET shared.TEST_006_004",
"id": "6fe9ec26-0bc5-4d81-9b3e-84992bf7aebd",
"properties": {
"name": "TEST_006_004",
"scope": "shared"
},
"x": 1360,
"y": 8940,
"width": 230,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"af3967f9-98a7-4648-942d-ac29c5cd059f": {
"title": "Stage 11",
"id": "af3967f9-98a7-4648-942d-ac29c5cd059f",
"properties": {
"stage": 11
},
"x": 2079,
"y": 9345,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"3463138c-e809-4eab-a291-29dc38c69fd2": {
"title": "Search Assets",
"id": "3463138c-e809-4eab-a291-29dc38c69fd2",
"properties": {
"vis_type": "ANY",
"character_name": "",
"tags": [],
"reference_vis_types": [],
"tag_match_mode": "all"
},
"x": 593,
"y": 9276,
"width": 329,
"height": 274,
"collapsed": false,
"inherited": false,
"registry": "assets/SearchAssets",
"base_type": "core/Node"
},
"7d04b355-c4b5-42cb-928b-f536e10cb580": {
"title": "Select Assets",
"id": "7d04b355-c4b5-42cb-928b-f536e10cb580",
"properties": {
"mode": "noop",
"vis_types": [
"SCENE_CARD"
],
"reference_vis_types": []
},
"x": 1320,
"y": 8690,
"width": 312,
"height": 166,
"collapsed": false,
"inherited": false,
"registry": "assets/SelectAssets",
"base_type": "core/Node"
},
"e2a11743-c988-4977-ae99-63b07498637e": {
"title": "Select Assets",
"id": "e2a11743-c988-4977-ae99-63b07498637e",
"properties": {
"mode": "prioritize",
"vis_types": [],
"reference_vis_types": [
"SCENE_ILLUSTRATION"
]
},
"x": 1361,
"y": 9299,
"width": 312,
"height": 166,
"collapsed": false,
"inherited": false,
"registry": "assets/SelectAssets",
"base_type": "core/Node"
},
"7b34adf4-4b48-4fc6-b759-546e692b8c69": {
"title": "SET shared.TEST_006_006",
"id": "7b34adf4-4b48-4fc6-b759-546e692b8c69",
"properties": {
"name": "TEST_006_006",
"scope": "shared"
},
"x": 1730,
"y": 9320,
"width": 230,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"3c1546d7-c255-46f7-97d8-4c81f2964460": {
"title": "Select Assets",
"id": "3c1546d7-c255-46f7-97d8-4c81f2964460",
"properties": {
"mode": "prioritize",
"vis_types": [],
"reference_vis_types": [
"CHARACTER_CARD"
]
},
"x": 990,
"y": 9294,
"width": 312,
"height": 166,
"collapsed": false,
"inherited": false,
"registry": "assets/SelectAssets",
"base_type": "core/Node"
}
},
"edges": {
@@ -1420,21 +1770,12 @@
"09c57a11-38e5-4c7d-a620-3b4fa8d9e7fc.asset_count": [
"6a875e86-a007-475e-9dc6-62bb20c8f3a5.value"
],
"80b727a6-4cc0-4b5d-a483-c4ce475dc07a.value": [
"7f1497f6-5ba2-42a2-8ab3-2613e42fbd1e.state"
],
"ee494b62-d657-4a88-afe8-cb3189ad865e.asset_ids": [
"697864a3-f547-404b-bfb8-51de8e7a1f9c.value"
],
"ee494b62-d657-4a88-afe8-cb3189ad865e.asset_count": [
"139d8712-ae1f-41be-9e6d-9baa71c3ccfa.value"
],
"f479210d-a8a9-4124-af30-887c1a2656fa.asset_ids": [
"80b727a6-4cc0-4b5d-a483-c4ce475dc07a.value"
],
"f479210d-a8a9-4124-af30-887c1a2656fa.asset_count": [
"5ed00c98-9a8b-4e6e-bd4f-73747cfaf58d.value"
],
"6d37ee86-8464-41f2-b2d0-16a7f5095dfd.value": [
"7f1497f6-5ba2-42a2-8ab3-2613e42fbd1e.state_b"
],
@@ -1452,6 +1793,64 @@
],
"24d8ea00-4970-4d9e-9b5e-37a24b7e7534.value": [
"7f1497f6-5ba2-42a2-8ab3-2613e42fbd1e.state_c"
],
"f479210d-a8a9-4124-af30-887c1a2656fa.asset_ids": [
"80b727a6-4cc0-4b5d-a483-c4ce475dc07a.value"
],
"f479210d-a8a9-4124-af30-887c1a2656fa.asset_count": [
"5ed00c98-9a8b-4e6e-bd4f-73747cfaf58d.value"
],
"80b727a6-4cc0-4b5d-a483-c4ce475dc07a.value": [
"7f1497f6-5ba2-42a2-8ab3-2613e42fbd1e.state"
],
"a2faf53d-4a55-4595-9ce9-ccc48adb6013.asset_count": [
"12d0d3d3-e283-442c-908a-7bd79a03f39b.a"
],
"12d0d3d3-e283-442c-908a-7bd79a03f39b.result": [
"7e93915f-f938-4d82-ae31-7e35a8039ed1.value"
],
"7e93915f-f938-4d82-ae31-7e35a8039ed1.value": [
"ace5e558-56d7-4b87-a188-850de13b972c.state"
],
"bdbaaa05-0cc2-4d93-b675-f0d491c32369.asset_ids": [
"139afdbe-bb44-43d0-93f3-3c5d9650929f.asset_ids"
],
"139afdbe-bb44-43d0-93f3-3c5d9650929f.asset_ids": [
"e6d2dbe5-cb93-4643-b5eb-6626023131a4.value"
],
"e6d2dbe5-cb93-4643-b5eb-6626023131a4.value": [
"a5653cab-96e7-4713-927e-f54cde3c98a7.state"
],
"5d98a7fb-ea97-4807-acf9-20b80bab97cb.asset_ids": [
"09a732cc-d6d0-474e-b584-bc17a7188ccc.asset_ids",
"ca08fd07-3207-4c61-981b-0731e60cd299.asset_ids"
],
"09a732cc-d6d0-474e-b584-bc17a7188ccc.asset_ids": [
"6fe9ec26-0bc5-4d81-9b3e-84992bf7aebd.value"
],
"ca08fd07-3207-4c61-981b-0731e60cd299.selection_context": [
"7d04b355-c4b5-42cb-928b-f536e10cb580.selection_context"
],
"5f46cf7a-bb80-418b-aa57-b86d7e626669.value": [
"9f1ba794-09d2-4738-9dbf-3d63ae20e4ff.state"
],
"6fe9ec26-0bc5-4d81-9b3e-84992bf7aebd.value": [
"9f1ba794-09d2-4738-9dbf-3d63ae20e4ff.state_b"
],
"3463138c-e809-4eab-a291-29dc38c69fd2.asset_ids": [
"3c1546d7-c255-46f7-97d8-4c81f2964460.asset_ids"
],
"7d04b355-c4b5-42cb-928b-f536e10cb580.asset_ids": [
"5f46cf7a-bb80-418b-aa57-b86d7e626669.value"
],
"e2a11743-c988-4977-ae99-63b07498637e.asset_ids": [
"7b34adf4-4b48-4fc6-b759-546e692b8c69.value"
],
"7b34adf4-4b48-4fc6-b759-546e692b8c69.value": [
"af3967f9-98a7-4648-942d-ac29c5cd059f.state"
],
"3c1546d7-c255-46f7-97d8-4c81f2964460.selection_context": [
"e2a11743-c988-4977-ae99-63b07498637e.selection_context"
]
},
"groups": [],

View File

@@ -1272,6 +1272,412 @@
"inherited": false,
"registry": "data/SelectItem",
"base_type": "core/Node"
},
"ecefb26f-1b8e-4d29-8c4a-fd24baa9acc3": {
"title": "SET shared.test_005_001",
"id": "ecefb26f-1b8e-4d29-8c4a-fd24baa9acc3",
"properties": {
"name": "test_005_001",
"scope": "shared"
},
"x": 857,
"y": 4182,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"fef2bbb2-c6b6-4d62-abc4-c23f29a6731d": {
"title": "Stage 6",
"id": "fef2bbb2-c6b6-4d62-abc4-c23f29a6731d",
"properties": {
"stage": 6
},
"x": 1290,
"y": 5125,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"23dd7f0c-c372-4489-833c-79ea143f5c24": {
"title": "Stage 7",
"id": "23dd7f0c-c372-4489-833c-79ea143f5c24",
"properties": {
"stage": 7
},
"x": 1290,
"y": 5425,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"98335435-8998-4c44-ae0a-dbe6d9aa0472": {
"title": "Stage 5",
"id": "98335435-8998-4c44-ae0a-dbe6d9aa0472",
"properties": {
"stage": 5
},
"x": 1297,
"y": 4842,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"db76e75c-a465-483e-8666-016ff30c30b9": {
"title": "Stage 4",
"id": "db76e75c-a465-483e-8666-016ff30c30b9",
"properties": {
"stage": 4
},
"x": 1297,
"y": 4382,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"b725d41b-7522-4f8b-a30c-75fe1295c6c0": {
"title": "SET shared.test_005_002",
"id": "b725d41b-7522-4f8b-a30c-75fe1295c6c0",
"properties": {
"name": "test_005_002",
"scope": "shared"
},
"x": 867,
"y": 4462,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"546542c4-d02f-4881-a4e5-2ed8ad1cb2b7": {
"title": "SET shared.test_005_003",
"id": "546542c4-d02f-4881-a4e5-2ed8ad1cb2b7",
"properties": {
"name": "test_005_003",
"scope": "shared"
},
"x": 667,
"y": 4832,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"7fb79425-1b85-4564-8877-3538981faccb": {
"title": "SET shared.test_005_004",
"id": "7fb79425-1b85-4564-8877-3538981faccb",
"properties": {
"name": "test_005_004",
"scope": "shared"
},
"x": 667,
"y": 5402,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"5a1a5a79-aa69-4ab9-a5e5-8de5a878e968": {
"title": "Make Bool",
"id": "5a1a5a79-aa69-4ab9-a5e5-8de5a878e968",
"properties": {
"value": true
},
"x": 297,
"y": 4132,
"width": 210,
"height": 58,
"collapsed": false,
"inherited": false,
"registry": "core/MakeBool",
"base_type": "core/Node"
},
"73c234c4-fc8e-4332-9c19-dde1b42c9ed3": {
"title": "Make Number",
"id": "73c234c4-fc8e-4332-9c19-dde1b42c9ed3",
"properties": {
"value": 100,
"number_type": "float"
},
"x": 227,
"y": 4252,
"width": 210,
"height": 82,
"collapsed": false,
"inherited": false,
"registry": "data/number/Make",
"base_type": "core/Node"
},
"6e2f777a-51e4-4ca6-9624-05ab6dbf77f2": {
"title": "Make Bool",
"id": "6e2f777a-51e4-4ca6-9624-05ab6dbf77f2",
"properties": {
"value": true
},
"x": 30,
"y": 5095,
"width": 210,
"height": 58,
"collapsed": false,
"inherited": false,
"registry": "core/MakeBool",
"base_type": "core/Node"
},
"d48bebda-5f9d-48e9-bc69-0bd45066de6d": {
"title": "Set State (Path)",
"id": "d48bebda-5f9d-48e9-bc69-0bd45066de6d",
"properties": {
"name": "a/b/c",
"scope": "game"
},
"x": 567,
"y": 4172,
"width": 210,
"height": 142,
"collapsed": false,
"inherited": false,
"registry": "state/SetStatePath",
"base_type": "core/Node"
},
"3fce4854-9b57-4faf-b94f-baa3fc580b69": {
"title": "Set State (Path)",
"id": "3fce4854-9b57-4faf-b94f-baa3fc580b69",
"properties": {
"name": "a/b/d",
"scope": "game"
},
"x": 567,
"y": 4422,
"width": 210,
"height": 142,
"collapsed": false,
"inherited": false,
"registry": "state/SetStatePath",
"base_type": "core/Node"
},
"09e86a88-f29b-4aee-89c9-07acf7df509b": {
"title": "GET local.a/b/c",
"id": "09e86a88-f29b-4aee-89c9-07acf7df509b",
"properties": {
"name": "a/b/c",
"scope": "game"
},
"x": 320,
"y": 4825,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/GetStatePath",
"base_type": "core/Node"
},
"d08e65b1-eba6-420f-9706-ff0a38aea629": {
"title": "Unset State (Path)",
"id": "d08e65b1-eba6-420f-9706-ff0a38aea629",
"properties": {
"name": "a/b/c",
"scope": "game"
},
"x": 310,
"y": 5095,
"width": 210,
"height": 142,
"collapsed": false,
"inherited": false,
"registry": "state/UnsetStatePath",
"base_type": "core/Node"
},
"412a1a3b-a6f0-447f-a0c3-fc63ecdbe009": {
"title": "GET local.a/b/c",
"id": "412a1a3b-a6f0-447f-a0c3-fc63ecdbe009",
"properties": {
"name": "a/b/c",
"scope": "game"
},
"x": 315,
"y": 5397,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/GetStatePath",
"base_type": "core/Node"
},
"bb32e785-2013-41a7-9f48-07289a43b0fc": {
"title": "GET local.a/b/c",
"id": "bb32e785-2013-41a7-9f48-07289a43b0fc",
"properties": {
"name": "a/b/d",
"scope": "game"
},
"x": 328,
"y": 5729,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/GetStatePath",
"base_type": "core/Node"
},
"396935ce-1f91-4777-8d18-a3ed7741a8c5": {
"title": "Stage 8",
"id": "396935ce-1f91-4777-8d18-a3ed7741a8c5",
"properties": {
"stage": 8
},
"x": 1287,
"y": 5752,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"c7eac293-5d6f-4dff-b6f7-b03898268c97": {
"title": "Make Bool",
"id": "c7eac293-5d6f-4dff-b6f7-b03898268c97",
"properties": {
"value": true
},
"x": 85,
"y": 6058,
"width": 210,
"height": 58,
"collapsed": false,
"inherited": false,
"registry": "core/MakeBool",
"base_type": "core/Node"
},
"3a25d084-51cb-4187-a765-fa4a1da48e41": {
"title": "Stage 9",
"id": "3a25d084-51cb-4187-a765-fa4a1da48e41",
"properties": {
"stage": 9
},
"x": 1287,
"y": 6092,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"d52d4b91-85f5-49c3-9281-aa944b0820a9": {
"title": "GET local.a/b/c",
"id": "d52d4b91-85f5-49c3-9281-aa944b0820a9",
"properties": {
"name": "a/b/d",
"scope": "game"
},
"x": 351,
"y": 6382,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/GetStatePath",
"base_type": "core/Node"
},
"58d01c85-1874-4e55-b9b6-ec335fd9a683": {
"title": "SET shared.test_005_005",
"id": "58d01c85-1874-4e55-b9b6-ec335fd9a683",
"properties": {
"name": "test_005_005",
"scope": "shared"
},
"x": 677,
"y": 5732,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"5749a1dc-c08c-4387-aa9f-906f417991a4": {
"title": "Stage 10",
"id": "5749a1dc-c08c-4387-aa9f-906f417991a4",
"properties": {
"stage": 10
},
"x": 1257,
"y": 6412,
"width": 210,
"height": 118,
"collapsed": false,
"inherited": false,
"registry": "core/Stage",
"base_type": "core/Node"
},
"bd8b158c-fd35-4689-9059-6f8c44736ecf": {
"title": "SET shared.test_005_006",
"id": "bd8b158c-fd35-4689-9059-6f8c44736ecf",
"properties": {
"name": "test_005_006",
"scope": "shared"
},
"x": 727,
"y": 6092,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"6b638bb2-af5f-4d61-a35c-9b1b900f3df5": {
"title": "SET shared.test_005_007",
"id": "6b638bb2-af5f-4d61-a35c-9b1b900f3df5",
"properties": {
"name": "test_005_007",
"scope": "shared"
},
"x": 697,
"y": 6392,
"width": 210,
"height": 122,
"collapsed": false,
"inherited": false,
"registry": "state/SetState",
"base_type": "core/Node"
},
"42a08c6c-446d-438d-b3c0-a50ad67ee2c7": {
"title": "UNSET game.a",
"id": "42a08c6c-446d-438d-b3c0-a50ad67ee2c7",
"properties": {
"name": "a",
"scope": "game"
},
"x": 367,
"y": 6062,
"width": 210,
"height": 142,
"collapsed": false,
"inherited": false,
"registry": "state/UnsetStatePath",
"base_type": "core/Node"
}
},
"edges": {
@@ -1537,6 +1943,65 @@
],
"e22ff79b-58a4-4eb4-a342-ff704c94ec08.selected_item": [
"25f4308d-0c42-467d-8bb2-ae456e3a17ba.a"
],
"ecefb26f-1b8e-4d29-8c4a-fd24baa9acc3.value": [
"db76e75c-a465-483e-8666-016ff30c30b9.state"
],
"b725d41b-7522-4f8b-a30c-75fe1295c6c0.value": [
"db76e75c-a465-483e-8666-016ff30c30b9.state_b"
],
"546542c4-d02f-4881-a4e5-2ed8ad1cb2b7.value": [
"98335435-8998-4c44-ae0a-dbe6d9aa0472.state"
],
"7fb79425-1b85-4564-8877-3538981faccb.value": [
"23dd7f0c-c372-4489-833c-79ea143f5c24.state"
],
"5a1a5a79-aa69-4ab9-a5e5-8de5a878e968.value": [
"d48bebda-5f9d-48e9-bc69-0bd45066de6d.state",
"3fce4854-9b57-4faf-b94f-baa3fc580b69.state"
],
"73c234c4-fc8e-4332-9c19-dde1b42c9ed3.value": [
"d48bebda-5f9d-48e9-bc69-0bd45066de6d.value",
"3fce4854-9b57-4faf-b94f-baa3fc580b69.value"
],
"6e2f777a-51e4-4ca6-9624-05ab6dbf77f2.value": [
"d08e65b1-eba6-420f-9706-ff0a38aea629.state"
],
"d48bebda-5f9d-48e9-bc69-0bd45066de6d.value": [
"ecefb26f-1b8e-4d29-8c4a-fd24baa9acc3.value"
],
"3fce4854-9b57-4faf-b94f-baa3fc580b69.value": [
"b725d41b-7522-4f8b-a30c-75fe1295c6c0.value"
],
"09e86a88-f29b-4aee-89c9-07acf7df509b.value": [
"546542c4-d02f-4881-a4e5-2ed8ad1cb2b7.value"
],
"d08e65b1-eba6-420f-9706-ff0a38aea629.value": [
"fef2bbb2-c6b6-4d62-abc4-c23f29a6731d.state"
],
"412a1a3b-a6f0-447f-a0c3-fc63ecdbe009.value": [
"7fb79425-1b85-4564-8877-3538981faccb.value"
],
"bb32e785-2013-41a7-9f48-07289a43b0fc.value": [
"58d01c85-1874-4e55-b9b6-ec335fd9a683.value"
],
"c7eac293-5d6f-4dff-b6f7-b03898268c97.value": [
"42a08c6c-446d-438d-b3c0-a50ad67ee2c7.state"
],
"d52d4b91-85f5-49c3-9281-aa944b0820a9.value": [
"6b638bb2-af5f-4d61-a35c-9b1b900f3df5.value"
],
"58d01c85-1874-4e55-b9b6-ec335fd9a683.value": [
"396935ce-1f91-4777-8d18-a3ed7741a8c5.state"
],
"bd8b158c-fd35-4689-9059-6f8c44736ecf.value": [
"3a25d084-51cb-4187-a765-fa4a1da48e41.state"
],
"6b638bb2-af5f-4d61-a35c-9b1b900f3df5.value": [
"5749a1dc-c08c-4387-aa9f-906f417991a4.state"
],
"42a08c6c-446d-438d-b3c0-a50ad67ee2c7.value": [
"bd8b158c-fd35-4689-9059-6f8c44736ecf.value"
]
},
"groups": [
@@ -1579,6 +2044,16 @@
"color": "#3f789e",
"font_size": 24,
"inherited": false
},
{
"title": "Test 005",
"x": 5,
"y": 4057,
"width": 1526,
"height": 2498,
"color": "#3f789e",
"font_size": 24,
"inherited": false
}
],
"comments": [],
@@ -1586,5 +2061,6 @@
"base_type": "core/Graph",
"inputs": [],
"outputs": [],
"module_properties": {},
"style": null
}

View File

@@ -24,7 +24,13 @@
"reference": [
"SCENE_BACKGROUND"
],
"analysis": null
"analysis": null,
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
}
},
"c26509ac97028c2ec0b696dc321dcb4883f4b3be1e3cfaf08d58b85154f4845d": {
@@ -54,7 +60,13 @@
"reference": [
"CHARACTER_CARD"
],
"analysis": null
"analysis": null,
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
}
},
"4928ce9046c66de3189b47f5bbcd3cafc92f37373845787c1eb64d08b29f4a1e": {
@@ -85,7 +97,13 @@
"SCENE_ILLUSTRATION",
"SCENE_CARD"
],
"analysis": null
"analysis": null,
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
}
},
"9d61cbb3ad289f1f8cf1eb69ee436a12416386cbede74f665357c2e58cd4d2a3": {
@@ -113,7 +131,13 @@
"holograms"
],
"reference": [],
"analysis": null
"analysis": null,
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
}
},
"37557902e5a7b5baab1fb02a9c69e48446906f491917668fc94fa47d5d76dba2": {
@@ -145,7 +169,13 @@
"CHARACTER_CARD",
"CHARACTER_PORTRAIT"
],
"analysis": null
"analysis": null,
"cover_bbox": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
}
}
}
}

View File

@@ -22,6 +22,11 @@ from talemate.game.engine.context_id import (
ContextIDNoHandlerFound,
CharacterContext,
)
from talemate.game.engine.context_id.story_configuration import (
DirectorInstructionsContextID,
StoryConfigurationContext,
StoryConfigurationContextItem,
)
from talemate.character import Character
from talemate.history import HistoryEntry
from talemate.world_state import ManualContext
@@ -1026,3 +1031,200 @@ def test_character_context_edge_cases(mock_scene):
# get_attribute and get_detail should return None for non-existent items
assert handler.get_attribute("nonexistent") is None
assert handler.get_detail("nonexistent") is None
# Tests for Story Configuration Context IDs
class MockSceneIntent:
"""Mock scene intent for testing story configuration context handlers."""
def __init__(self):
self.intent = "Overall story intention"
self.instructions = "Director instructions for managing the scene"
self.phase = None
self.scene_types = {}
class MockSceneForStoryConfig:
"""Mock scene for testing story configuration context handlers."""
def __init__(self):
self.title = "Test Story"
self.name = "test_story"
self.description = "A test story description"
self.context = "Adult themes, fantasy setting"
self.intent_state = MockSceneIntent()
self.characters = {}
def get_intro(self):
return "This is the story introduction."
def set_intro(self, value):
self._intro = value
def emit_scene_intent(self):
"""Mock emit method."""
pass
@pytest.fixture
def mock_scene_story_config():
"""Create a mock scene for testing story configuration context IDs."""
return MockSceneForStoryConfig()
def test_director_instructions_context_id_creation():
"""Test DirectorInstructionsContextID creation."""
context_id = DirectorInstructionsContextID.make()
assert context_id.key == "director_instructions"
assert context_id.path == ["director_instructions"]
assert context_id.context_type == "story_configuration"
assert context_id.path_to_str == "story_configuration:director_instructions"
def test_director_instructions_context_id_properties():
"""Test DirectorInstructionsContextID properties."""
context_id = DirectorInstructionsContextID.make()
assert str(context_id) == "story_configuration:director_instructions"
assert context_id.id == "story_configuration:director_instructions"
assert "director" in context_id.context_type_label.lower()
def test_story_configuration_context_handler_registration():
"""Test that StoryConfigurationContext handler is properly registered."""
assert "story_configuration" in CONTEXT_ID_PATH_HANDLERS
assert CONTEXT_ID_PATH_HANDLERS["story_configuration"] == StoryConfigurationContext
@pytest.mark.asyncio
async def test_director_instructions_context_item_get(mock_scene_story_config):
"""Test getting director instructions through context item."""
handler = StoryConfigurationContext.instance_from_path(
["director_instructions"], mock_scene_story_config
)
assert handler is not None
item = await handler.context_id_item_from_path(
"story_configuration",
["director_instructions"],
"story_configuration:director_instructions",
mock_scene_story_config,
)
assert item is not None
assert item.context_type == "director_instructions"
assert item.name == "director_instructions"
assert item.value == "Director instructions for managing the scene"
# Test get method
value = await item.get(mock_scene_story_config)
assert value == "Director instructions for managing the scene"
@pytest.mark.asyncio
async def test_director_instructions_context_item_set(mock_scene_story_config):
"""Test setting director instructions through context item."""
handler = StoryConfigurationContext.instance_from_path(
["director_instructions"], mock_scene_story_config
)
item = await handler.context_id_item_from_path(
"story_configuration",
["director_instructions"],
"story_configuration:director_instructions",
mock_scene_story_config,
)
# Test set method
new_instructions = "Updated director instructions"
await item.set(mock_scene_story_config, new_instructions)
assert mock_scene_story_config.intent_state.instructions == new_instructions
# Test setting to None
await item.set(mock_scene_story_config, None)
assert mock_scene_story_config.intent_state.instructions is None
# Test setting empty string converts to None
await item.set(mock_scene_story_config, "")
assert mock_scene_story_config.intent_state.instructions is None
@pytest.mark.asyncio
async def test_director_instructions_context_id_from_path(mock_scene_story_config):
"""Test getting DirectorInstructionsContextID from path."""
handler = StoryConfigurationContext.instance_from_path(
["director_instructions"], mock_scene_story_config
)
context_id = await handler.context_id_from_path(
"story_configuration",
["director_instructions"],
"story_configuration:director_instructions",
mock_scene_story_config,
)
assert context_id is not None
assert isinstance(context_id, DirectorInstructionsContextID)
assert context_id.path == ["director_instructions"]
def test_director_instructions_context_item_properties(mock_scene_story_config):
"""Test DirectorInstructionsContextItem properties."""
item = StoryConfigurationContextItem(
context_type="director_instructions",
name="director_instructions",
value="Test instructions",
)
# Test context_id property
context_id = item.context_id
assert isinstance(context_id, DirectorInstructionsContextID)
# Test human_id property
assert item.human_id == "Director Instructions"
@pytest.mark.asyncio
async def test_director_instructions_integration_flow(mock_scene_story_config):
"""Test complete integration flow for director instructions context ID."""
context_id_str = "story_configuration:director_instructions"
# Test context_id_item_from_string
context_item = await context_id_item_from_string(
context_id_str, mock_scene_story_config
)
assert context_item is not None
assert context_item.context_type == "director_instructions"
assert context_item.value == "Director instructions for managing the scene"
# Test context_id_from_string
context_id = await context_id_from_string(context_id_str, mock_scene_story_config)
assert context_id is not None
assert isinstance(context_id, DirectorInstructionsContextID)
# Test get and set operations
original_value = await context_item.get(mock_scene_story_config)
assert original_value == "Director instructions for managing the scene"
new_value = "New instructions for the director"
await context_item.set(mock_scene_story_config, new_value)
updated_value = await context_item.get(mock_scene_story_config)
assert updated_value == new_value
assert mock_scene_story_config.intent_state.instructions == new_value
@pytest.mark.asyncio
async def test_director_instructions_with_none_value(mock_scene_story_config):
"""Test director instructions context ID when instructions are None."""
mock_scene_story_config.intent_state.instructions = None
context_id_str = "story_configuration:director_instructions"
context_item = await context_id_item_from_string(
context_id_str, mock_scene_story_config
)
assert context_item is not None
value = await context_item.get(mock_scene_story_config)
assert value is None

View File

@@ -2,6 +2,7 @@ import os
import json
import pytest
import contextvars
import enum
import talemate.agents as agents
import pydantic
import talemate.game.engine.nodes.load_definitions # noqa: F401
@@ -191,6 +192,8 @@ def normalize_state(data):
"""Convert Pydantic models to dicts for comparison"""
if isinstance(data, pydantic.BaseModel):
return data.model_dump()
elif isinstance(data, enum.Enum):
return data.value
elif isinstance(data, dict):
return {k: normalize_state(v) for k, v in data.items()}
elif isinstance(data, list):

209
tests/test_path.py Normal file
View File

@@ -0,0 +1,209 @@
import pytest
from talemate.util.path import split_state_path, get_path_parent
from talemate.game.engine.nodes.core import InputValueError
class TestSplitStatePath:
"""Tests for split_state_path function."""
def test_simple_path(self):
"""Test splitting a simple path."""
assert split_state_path("a/b/c") == ["a", "b", "c"]
def test_single_segment(self):
"""Test splitting a single segment path."""
assert split_state_path("a") == ["a"]
def test_leading_slash(self):
"""Test path with leading slash."""
assert split_state_path("/a/b/c") == ["a", "b", "c"]
def test_trailing_slash(self):
"""Test path with trailing slash."""
assert split_state_path("a/b/c/") == ["a", "b", "c"]
def test_both_slashes(self):
"""Test path with both leading and trailing slashes."""
assert split_state_path("/a/b/c/") == ["a", "b", "c"]
def test_multiple_slashes(self):
"""Test path with multiple consecutive slashes."""
assert split_state_path("a//b///c") == ["a", "b", "c"]
def test_empty_string(self):
"""Test that empty string raises ValueError."""
with pytest.raises(ValueError, match="Path name cannot be empty"):
split_state_path("")
def test_only_slashes(self):
"""Test that string with only slashes raises ValueError."""
with pytest.raises(ValueError, match="Path name cannot be empty"):
split_state_path("///")
def test_whitespace_handling(self):
"""Test that whitespace is preserved in segments."""
assert split_state_path("a/b c/d") == ["a", "b c", "d"]
class TestGetPathParent:
"""Tests for get_path_parent function."""
def test_simple_path_create(self):
"""Test creating a simple path."""
container = {}
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=True)
assert leaf == "c"
assert isinstance(parent, dict)
assert "a" in container
assert isinstance(container["a"], dict)
assert "b" in container["a"]
assert isinstance(container["a"]["b"], dict)
def test_simple_path_no_create(self):
"""Test traversing existing path without creating."""
container = {"a": {"b": {"c": 42}}}
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=False)
assert leaf == "c"
assert parent == container["a"]["b"]
assert parent["c"] == 42
def test_missing_path_no_create(self):
"""Test missing path without create returns None."""
container = {}
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=False)
assert parent is None
assert leaf == "c"
def test_partial_path_no_create(self):
"""Test partial existing path without create returns None."""
container = {"a": {}}
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=False)
assert parent is None
assert leaf == "c"
def test_single_segment_create(self):
"""Test single segment path with create."""
container = {}
parts = ["a"]
parent, leaf = get_path_parent(container, parts, create=True)
assert leaf == "a"
assert parent == container
def test_single_segment_no_create(self):
"""Test single segment path."""
container = {"a": 42}
parts = ["a"]
parent, leaf = get_path_parent(container, parts, create=False)
assert leaf == "a"
assert parent == container
def test_conflict_non_dict_intermediate(self):
"""Test that non-dict intermediate raises error when create=True."""
container = {"a": 5} # 'a' is not a dict
parts = ["a", "b", "c"]
with pytest.raises(
ValueError, match="Path segment 'a' exists but is not a dictionary"
):
get_path_parent(container, parts, create=True)
def test_conflict_non_dict_intermediate_with_node(self):
"""Test that non-dict intermediate raises InputValueError when node provided."""
from talemate.game.engine.nodes.core import Node
# Create a mock node
mock_node = Node(title="Test Node")
container = {"a": 5} # 'a' is not a dict
parts = ["a", "b", "c"]
with pytest.raises(InputValueError):
get_path_parent(container, parts, create=True, node_for_errors=mock_node)
def test_conflict_deep_path(self):
"""Test conflict in deeper path."""
container = {"a": {"b": 5}} # 'b' is not a dict
parts = ["a", "b", "c"]
with pytest.raises(
ValueError, match="Path segment 'a/b' exists but is not a dictionary"
):
get_path_parent(container, parts, create=True)
def test_empty_parts(self):
"""Test that empty parts list raises ValueError."""
container = {}
with pytest.raises(ValueError, match="Path parts cannot be empty"):
get_path_parent(container, [], create=True)
def test_nested_creation(self):
"""Test creating deeply nested structure."""
container = {}
parts = ["level1", "level2", "level3", "level4", "key"]
parent, leaf = get_path_parent(container, parts, create=True)
assert leaf == "key"
assert isinstance(parent, dict)
assert container["level1"]["level2"]["level3"]["level4"] == parent
def test_existing_intermediate_dicts(self):
"""Test that existing dicts are reused."""
container = {"a": {"b": {"existing": "value"}}}
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=True)
assert leaf == "c"
assert parent == container["a"]["b"]
# Existing key should still be there
assert parent["existing"] == "value"
# New key can be set
parent[leaf] = "new_value"
assert container["a"]["b"]["c"] == "new_value"
def test_dict_like_container(self):
"""Test with dict-like container that has get method."""
class DictLike:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def __setitem__(self, key, value):
self._data[key] = value
def __getitem__(self, key):
return self._data[key]
def __contains__(self, key):
return key in self._data
container = DictLike()
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=True)
assert leaf == "c"
assert isinstance(parent, dict)
assert "a" in container._data
assert isinstance(container._data["a"], dict)
def test_no_get_method_container(self):
"""Test with container that doesn't have get method."""
container = {}
parts = ["a", "b", "c"]
parent, leaf = get_path_parent(container, parts, create=True)
assert leaf == "c"
assert isinstance(parent, dict)

242
tests/test_tts_util.py Normal file
View File

@@ -0,0 +1,242 @@
"""Tests for TTS utility functions."""
from talemate.agents.tts.util import split_long_chunk, parse_chunks, rejoin_chunks
class TestSplitLongChunk:
"""Tests for split_long_chunk function."""
def test_short_text_returns_unchanged(self):
"""Text shorter than chunk_size should be returned as-is."""
text = "Hello world"
result = split_long_chunk(text, chunk_size=50)
assert result == [text]
def test_text_equal_to_chunk_size_returns_unchanged(self):
"""Text exactly equal to chunk_size should be returned as-is."""
text = "Hello world" # 11 chars
result = split_long_chunk(text, chunk_size=11)
assert result == [text]
def test_splits_at_comma(self):
"""Should prefer splitting at comma boundaries."""
text = "Hello world, this is a test, and more text here"
result = split_long_chunk(text, chunk_size=30)
# Should split at ", " after "this is a test"
assert len(result) >= 2
# Each chunk should be <= chunk_size
for chunk in result:
assert len(chunk) <= 30
def test_splits_at_space_when_no_comma(self):
"""Should split at space when no comma is available."""
text = "Hello world this is a test without commas here"
result = split_long_chunk(text, chunk_size=25)
assert len(result) >= 2
for chunk in result:
assert len(chunk) <= 25
def test_hard_split_when_no_boundaries(self):
"""Should hard split when no natural boundaries exist."""
text = "abcdefghijklmnopqrstuvwxyz" # 26 chars, no spaces or commas
result = split_long_chunk(text, chunk_size=10)
assert len(result) == 3
assert result[0] == "abcdefghij"
assert result[1] == "klmnopqrst"
assert result[2] == "uvwxyz"
def test_preserves_all_content(self):
"""Splitting and rejoining should preserve all content."""
text = "Hello world, this is a longer test text, with multiple commas, and spaces throughout the entire string"
result = split_long_chunk(text, chunk_size=30)
rejoined = " ".join(result)
# Content should be preserved (whitespace may differ due to strip())
assert "Hello world" in rejoined
assert "multiple commas" in rejoined
assert "entire string" in rejoined
def test_handles_long_text_with_many_splits(self):
"""Should handle text requiring many splits."""
text = "word " * 100 # 500 chars
result = split_long_chunk(text, chunk_size=50)
assert len(result) > 1
for chunk in result:
assert len(chunk) <= 50
def test_comma_must_be_in_second_half(self):
"""Comma split point must be in second half of chunk to be used."""
# Comma at position 5 in a 50-char chunk_size (5 < 25) should not be used
text = "Hi, " + "a" * 100 # Comma very early
result = split_long_chunk(text, chunk_size=50)
# Should split at space or hard boundary, not at the early comma
assert len(result) >= 2
def test_space_must_be_in_second_half(self):
"""Space split point must be in second half of chunk to be used."""
# Space at position 2 in a 50-char chunk_size (2 < 25) should not be used
text = "Hi " + "a" * 100 # Space very early
result = split_long_chunk(text, chunk_size=50)
assert len(result) >= 2
def test_empty_string(self):
"""Empty string should return list with empty string."""
result = split_long_chunk("", chunk_size=50)
assert result == [""]
def test_strips_whitespace_from_chunks(self):
"""Chunks should have leading/trailing whitespace stripped."""
text = "Hello world, this is a test"
result = split_long_chunk(text, chunk_size=15)
for chunk in result:
assert chunk == chunk.strip()
def test_realistic_tts_scenario(self):
"""Test with realistic TTS text that caused the original bug."""
text = (
"Leaning forward with deliberate calm she let her fingers rest lightly "
"on Jon's shoulder before speaking in that measured legal cadence that "
"had won cases but now trembled with something raw The Last Serial Killer "
"she said letting the title hang between them like evidence presented in court"
)
result = split_long_chunk(text, chunk_size=256)
# Original text is ~300 chars, should be split into 2 chunks
assert len(result) == 2
for chunk in result:
assert len(chunk) <= 256
class TestParseChunks:
"""Tests for parse_chunks function."""
def test_simple_sentences(self):
"""Should split text into sentences."""
text = "Hello world. This is a test. Another sentence here."
result = parse_chunks(text)
assert len(result) == 3
assert result[0] == "Hello world."
assert result[1] == "This is a test."
assert result[2] == "Another sentence here."
def test_removes_asterisks(self):
"""Should remove asterisks from text."""
text = "*Hello* world. *This* is a test."
result = parse_chunks(text)
assert "*" not in " ".join(result)
def test_preserves_ellipsis(self):
"""Should preserve ellipsis in text."""
text = "Hello... world. This is... a test."
result = parse_chunks(text)
rejoined = " ".join(result)
assert "..." in rejoined
def test_adds_period_before_opening_quote(self):
"""Should add period before opening quote when preceded by word."""
text = 'He said something "Hello world" and left.'
result = parse_chunks(text)
# The text should be split with proper sentence boundaries
assert len(result) >= 1
def test_adds_period_after_quote_without_terminator(self):
"""Should add period after quote that lacks sentence terminator."""
text = '"Hello world" he said'
result = parse_chunks(text)
# Should become '"Hello world." he said' and then split
rejoined = " ".join(result)
# The quote should have a period added inside
assert '"Hello world."' in rejoined or "Hello world." in rejoined
def test_preserves_quote_with_existing_terminator(self):
"""Should not add extra period when quote already has terminator."""
text = '"Hello world!" he said.'
result = parse_chunks(text)
rejoined = " ".join(result)
# Should not have double punctuation
assert '!."' not in rejoined and '!".' not in rejoined
def test_handles_empty_chunks(self):
"""Should filter out empty chunks."""
text = "Hello. . World."
result = parse_chunks(text)
for chunk in result:
assert chunk.strip() != ""
def test_handles_empty_string(self):
"""Should handle empty string input."""
result = parse_chunks("")
assert result == []
def test_realistic_dialogue(self):
"""Test with realistic dialogue text."""
text = (
'She leaned forward "The Last Serial Killer" she said '
'"It\'s not just another slasher flick" Her voice dropped lower.'
)
result = parse_chunks(text)
# Should properly split around quotes
assert len(result) >= 2
class TestRejoinChunks:
"""Tests for rejoin_chunks function."""
def test_combines_small_chunks(self):
"""Should combine small chunks up to chunk_size."""
chunks = ["Hello.", "World.", "Test."]
result = rejoin_chunks(chunks, chunk_size=50)
assert len(result) == 1
assert result[0] == "Hello. World. Test."
def test_respects_chunk_size_limit(self):
"""Should not exceed chunk_size when combining."""
chunks = ["Hello world.", "This is a test.", "Another sentence."]
result = rejoin_chunks(chunks, chunk_size=20)
for chunk in result:
assert len(chunk) <= 20
def test_splits_oversized_chunks(self):
"""Should split individual chunks that exceed chunk_size."""
chunks = ["This is a very long sentence that exceeds the chunk size limit"]
result = rejoin_chunks(chunks, chunk_size=20)
assert len(result) > 1
for chunk in result:
assert len(chunk) <= 20
def test_adds_space_between_chunks(self):
"""Should add space when combining chunks."""
chunks = ["Hello.", "World."]
result = rejoin_chunks(chunks, chunk_size=50)
assert result[0] == "Hello. World."
def test_handles_empty_list(self):
"""Should handle empty chunk list."""
result = rejoin_chunks([], chunk_size=50)
assert result == []
def test_handles_single_chunk(self):
"""Should handle single chunk that fits."""
chunks = ["Hello world."]
result = rejoin_chunks(chunks, chunk_size=50)
assert result == ["Hello world."]
def test_flushes_before_oversized_chunk(self):
"""Should flush current chunk before adding oversized chunk pieces."""
chunks = ["Hi.", "This is a very long sentence that needs splitting"]
result = rejoin_chunks(chunks, chunk_size=20)
# "Hi." should be separate, then the long sentence split
assert result[0] == "Hi."
assert len(result) > 1
def test_realistic_scenario(self):
"""Test with realistic sentence chunks."""
chunks = [
"She looked at him.",
"The room was quiet.",
"He wondered what she would say next.",
"Time seemed to slow down.",
]
result = rejoin_chunks(chunks, chunk_size=100)
# Should combine into larger chunks
assert len(result) < len(chunks)
for chunk in result:
assert len(chunk) <= 100

View File

@@ -3,6 +3,7 @@ from talemate.util.prompt import (
parse_response_section,
extract_actions_block,
clean_visible_response,
auto_close_tags,
)
@@ -288,6 +289,146 @@ And here are the actions to take:
assert len(result) == 1
assert result[0]["name"] == "real_action"
def test_actions_without_code_fence(self):
"""Test extracting ACTIONS block without code fence wrapper."""
response = """
<ACTIONS>
[
{"name": "update_context", "instructions": "Create a character"},
{"name": "start_roleplay", "instructions": ""}
]
</ACTIONS>
"""
content = extract_actions_block(response)
result = parse_actions_content(content)
assert result is not None
assert len(result) == 2
assert result[0]["name"] == "update_context"
assert result[1]["name"] == "start_roleplay"
def test_actions_without_code_fence_single_object(self):
"""Test extracting ACTIONS block without code fence, single object."""
response = """
<ACTIONS>
{"name": "test_action", "instructions": "Do something"}
</ACTIONS>
"""
content = extract_actions_block(response)
result = parse_actions_content(content)
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "test_action"
def test_actions_without_code_fence_after_analysis(self):
"""Test ACTIONS without code fence after ANALYSIS block."""
response = """
<ANALYSIS>
Some analysis here.
</ANALYSIS>
<MESSAGE>
Response text.
</MESSAGE>
<ACTIONS>
[{"name": "real_action", "instructions": "Do this"}]
</ACTIONS>
"""
content = extract_actions_block(response)
result = parse_actions_content(content)
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "real_action"
def test_actions_without_code_fence_realistic_example(self):
"""Test with the realistic example from user."""
response = """
<ACTIONS>
[
{
"name": "update_context",
"instructions": "Create a new player-controlled character named Veyla. Description: 'Veyla is a lean, mid-twenties rogue with travel-stained clothes and eyes that flicker between wariness and quiet hope. Her hands move like smoke calloused from travel but never raised in violence. A satchel bulging with stolen trinkets hangs at her hip, and she carries the scent of rain and Qeynos cobbles. She's been on the road for months, with Freeport as her last desperate hope. She'll steal bread but won't break a neck.' Set as active in scene with no entry narration needed."
},
{
"name": "start_roleplay",
"instructions": ""
}
]
</ACTIONS>
"""
content = extract_actions_block(response)
result = parse_actions_content(content)
assert result is not None
assert len(result) == 2
assert result[0]["name"] == "update_context"
assert "Veyla" in result[0]["instructions"]
assert result[1]["name"] == "start_roleplay"
def test_actions_prefers_code_fence_over_no_fence(self):
"""Test that code-fenced content is preferred over non-fenced."""
response = """
<ACTIONS>
```json
[{"name": "fenced_action", "instructions": "From fence"}]
```
</ACTIONS>
"""
content = extract_actions_block(response)
result = parse_actions_content(content)
assert result is not None
assert result[0]["name"] == "fenced_action"
def test_actions_without_code_fence_ignores_non_json(self):
"""Test that non-JSON/YAML content without code fence is not extracted."""
response = """
<ACTIONS>
This is just plain text, not JSON or YAML.
</ACTIONS>
"""
content = extract_actions_block(response)
# Should return None because content doesn't look like structured data
assert content is None
def test_actions_without_code_fence_yaml_list(self):
"""Test extracting ACTIONS block with YAML list format (no code fence)."""
response = """
<ACTIONS>
- name: update_context
instructions: Create a character named Veyla
- name: start_roleplay
instructions: ""
</ACTIONS>
"""
content = extract_actions_block(response)
assert content is not None
assert "- name: update_context" in content
assert "- name: start_roleplay" in content
def test_actions_without_code_fence_yaml_object(self):
"""Test extracting ACTIONS block with YAML object format (no code fence)."""
response = """
<ACTIONS>
name: test_action
instructions: Do something important
</ACTIONS>
"""
content = extract_actions_block(response)
assert content is not None
assert "name: test_action" in content
assert "instructions: Do something important" in content
def test_actions_with_yaml_code_fence(self):
"""Test extracting ACTIONS block with YAML code fence."""
response = """
<ACTIONS>
```yaml
- name: yaml_action
instructions: This is YAML
```
</ACTIONS>
"""
content = extract_actions_block(response)
assert content is not None
assert "- name: yaml_action" in content
# ============================================================================
# Tests for clean_visible_response
@@ -391,3 +532,153 @@ End."""
assert "<ACTIONS>" not in result
assert "first" not in result
assert "second" not in result
# ============================================================================
# Tests for auto_close_tags
# ============================================================================
class TestAutoCloseTags:
"""Tests for the auto_close_tags function."""
def test_unclosed_analysis_before_message(self):
"""Test that unclosed ANALYSIS is closed before MESSAGE."""
text = "<ANALYSIS>Some analysis text<MESSAGE>Hello</MESSAGE>"
result = auto_close_tags(text)
assert "</ANALYSIS>" in result
assert result.index("</ANALYSIS>") < result.index("<MESSAGE>")
def test_unclosed_analysis_before_decision(self):
"""Test that unclosed ANALYSIS is closed before DECISION."""
text = "<ANALYSIS>Some analysis<DECISION>Option A</DECISION>"
result = auto_close_tags(text)
assert "</ANALYSIS>" in result
assert result.index("</ANALYSIS>") < result.index("<DECISION>")
def test_unclosed_analysis_before_actions(self):
"""Test that unclosed ANALYSIS is closed before ACTIONS."""
text = "<ANALYSIS>Some analysis<ACTIONS>```json\n[]\n```</ACTIONS>"
result = auto_close_tags(text)
assert "</ANALYSIS>" in result
assert result.index("</ANALYSIS>") < result.index("<ACTIONS>")
def test_unclosed_message_before_decision(self):
"""Test that unclosed MESSAGE is closed before DECISION."""
text = "<MESSAGE>Hello<DECISION>Option A</DECISION>"
result = auto_close_tags(text)
assert "</MESSAGE>" in result
assert result.index("</MESSAGE>") < result.index("<DECISION>")
def test_unclosed_message_before_actions(self):
"""Test that unclosed MESSAGE is closed before ACTIONS."""
text = "<MESSAGE>Hello<ACTIONS>```json\n[]\n```</ACTIONS>"
result = auto_close_tags(text)
assert "</MESSAGE>" in result
assert result.index("</MESSAGE>") < result.index("<ACTIONS>")
def test_multiple_unclosed_tags(self):
"""Test that multiple unclosed tags are all closed."""
text = "<ANALYSIS>Analysis<MESSAGE>Hello<DECISION>Choice<ACTIONS>```json\n[]\n```</ACTIONS>"
result = auto_close_tags(text)
assert "</ANALYSIS>" in result
assert "</MESSAGE>" in result
assert "</DECISION>" in result
# Verify order
assert result.index("</ANALYSIS>") < result.index("<MESSAGE>")
assert result.index("</MESSAGE>") < result.index("<DECISION>")
assert result.index("</DECISION>") < result.index("<ACTIONS>")
def test_properly_closed_tags_unchanged(self):
"""Test that properly closed tags are not modified."""
text = "<ANALYSIS>Some analysis</ANALYSIS><MESSAGE>Hello</MESSAGE>"
result = auto_close_tags(text)
assert result == text
def test_case_insensitive_tags(self):
"""Test that tag matching is case insensitive."""
text = "<analysis>Some analysis<message>Hello</message>"
result = auto_close_tags(text)
assert "</ANALYSIS>" in result
assert result.lower().index("</analysis>") < result.lower().index("<message>")
def test_empty_string(self):
"""Test that empty string returns empty string."""
assert auto_close_tags("") == ""
def test_no_tags(self):
"""Test that text without tags is unchanged."""
text = "Just plain text without any tags."
assert auto_close_tags(text) == text
def test_single_unclosed_tag_no_next_tag(self):
"""Test that single unclosed tag without following tag is unchanged."""
text = "<ANALYSIS>Some analysis that never closes"
result = auto_close_tags(text)
# No closing tag should be added because there's no next opening tag
assert "</ANALYSIS>" not in result
def test_realistic_llm_response(self):
"""Test with a realistic LLM response format from the user's example."""
text = """<ANALYSIS>
1. Current scene state: We're in startup mode.
2. Story need: The narrative must immediately establish Veyla.
<MESSAGE>
Character created and setup complete. Transitioning to roleplay phase.
<DECISION>
Taking three actions: create character, update game state, start roleplay.
<ACTIONS>
[
{"name": "update_context", "instructions": "Create persistent character"}
]
</ACTIONS>"""
result = auto_close_tags(text)
# ANALYSIS should be closed before MESSAGE
assert "</ANALYSIS>" in result
assert result.index("</ANALYSIS>") < result.index("<MESSAGE>")
# MESSAGE should be closed before DECISION
assert "</MESSAGE>" in result
assert result.index("</MESSAGE>") < result.index("<DECISION>")
# DECISION should be closed before ACTIONS
assert "</DECISION>" in result
assert result.index("</DECISION>") < result.index("<ACTIONS>")
def test_parse_response_with_auto_close(self):
"""Test that auto_close_tags enables parse_response_section to work correctly."""
# Without auto_close_tags, the MESSAGE wouldn't be properly extracted
text = """<ANALYSIS>
Some analysis here.
<MESSAGE>
This is the actual message.
</MESSAGE>"""
fixed_text = auto_close_tags(text)
result = parse_response_section(fixed_text)
assert result == "This is the actual message."
def test_extract_actions_with_auto_close(self):
"""Test that auto_close_tags enables extract_actions_block to work correctly."""
text = """<ANALYSIS>
Some analysis here.
<ACTIONS>
```json
[{"name": "test_action", "instructions": "Do something"}]
```
</ACTIONS>"""
fixed_text = auto_close_tags(text)
content = extract_actions_block(fixed_text)
result = parse_actions_content(content)
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "test_action"
def test_preserves_content_between_tags(self):
"""Test that content between tags is preserved correctly."""
text = "<ANALYSIS>Line1\nLine2\nLine3<MESSAGE>Response text</MESSAGE>"
result = auto_close_tags(text)
assert "Line1\nLine2\nLine3" in result
assert "Response text" in result

313
tests/test_utils_ux.py Normal file
View File

@@ -0,0 +1,313 @@
from talemate.game.engine.ux.schema import UXChoice
from talemate.util.ux import json_load_maybe, normalize_choices
def test_json_load_maybe_valid_json():
"""Test json_load_maybe with valid JSON strings."""
# Test with a JSON object
result = json_load_maybe('{"name": "test", "value": 42}')
assert result == {"name": "test", "value": 42}
# Test with a JSON array
result = json_load_maybe('[1, 2, 3, "four"]')
assert result == [1, 2, 3, "four"]
# Test with a JSON string
result = json_load_maybe('"hello world"')
assert result == "hello world"
# Test with a JSON number
result = json_load_maybe("123")
assert result == 123
# Test with a JSON boolean
result = json_load_maybe("true")
assert result is True
result = json_load_maybe("false")
assert result is False
# Test with null
result = json_load_maybe("null")
assert result is None
def test_json_load_maybe_invalid_json():
"""Test json_load_maybe with invalid JSON strings returns the original string."""
# Invalid JSON should return the original string
invalid_json = '{"name": "test", "value":'
result = json_load_maybe(invalid_json)
assert result == invalid_json
# Plain text should return as-is
plain_text = "This is not JSON"
result = json_load_maybe(plain_text)
assert result == plain_text
# Empty string
result = json_load_maybe("")
assert result == ""
# String with special characters
special = "Hello {world} [test]"
result = json_load_maybe(special)
assert result == special
def test_normalize_choices_none():
"""Test normalize_choices with None returns empty list."""
result = normalize_choices(None)
assert result == []
def test_normalize_choices_list_of_strings():
"""Test normalize_choices with a list of strings."""
choices = ["Option 1", "Option 2", "Option 3"]
result = normalize_choices(choices)
assert len(result) == 3
assert result[0].id == "choice_0"
assert result[0].label == "Option 1"
assert result[0].value == "Option 1"
assert result[1].id == "choice_1"
assert result[1].label == "Option 2"
assert result[1].value == "Option 2"
assert result[2].id == "choice_2"
assert result[2].label == "Option 3"
assert result[2].value == "Option 3"
def test_normalize_choices_list_of_dicts():
"""Test normalize_choices with a list of dictionaries."""
choices = [
{"id": "opt1", "label": "Option 1", "value": "val1"},
{"id": "opt2", "label": "Option 2", "value": "val2"},
]
result = normalize_choices(choices)
assert len(result) == 2
assert result[0].id == "opt1"
assert result[0].label == "Option 1"
assert result[0].value == "val1"
assert result[1].id == "opt2"
assert result[1].label == "Option 2"
assert result[1].value == "val2"
def test_normalize_choices_list_of_dicts_minimal():
"""Test normalize_choices with minimal dictionaries (missing id)."""
choices = [
{"label": "Option 1", "value": "val1"},
{"label": "Option 2", "value": "val2"},
]
result = normalize_choices(choices)
assert len(result) == 2
assert result[0].id == "choice_0"
assert result[0].label == "Option 1"
assert result[0].value == "val1"
assert result[1].id == "choice_1"
assert result[1].label == "Option 2"
assert result[1].value == "val2"
def test_normalize_choices_list_of_dicts_embedded_label():
"""Test normalize_choices with dicts where label is a key."""
choices = [
{"id": "opt1", "Option 1": "val1"},
{"id": "opt2", "Option 2": "val2"},
]
result = normalize_choices(choices)
assert len(result) == 2
assert result[0].id == "opt1"
assert result[0].label == "Option 1"
assert result[0].value == "val1"
assert result[1].id == "opt2"
assert result[1].label == "Option 2"
assert result[1].value == "val2"
def test_normalize_choices_list_of_uxchoice():
"""Test normalize_choices with a list of UXChoice objects."""
choices = [
UXChoice(id="opt1", label="Option 1", value="val1"),
UXChoice(id="opt2", label="Option 2", value="val2"),
]
result = normalize_choices(choices)
assert len(result) == 2
assert result[0].id == "opt1"
assert result[0].label == "Option 1"
assert result[0].value == "val1"
assert result[1].id == "opt2"
assert result[1].label == "Option 2"
assert result[1].value == "val2"
def test_normalize_choices_dict_label_to_value():
"""Test normalize_choices with a dictionary mapping labels to values."""
choices = {
"Option 1": "val1",
"Option 2": "val2",
"Option 3": "val3",
}
result = normalize_choices(choices)
assert len(result) == 3
# Order may vary, so check by label
labels = {choice.label: choice for choice in result}
assert "Option 1" in labels
assert labels["Option 1"].value == "val1"
assert "Option 2" in labels
assert labels["Option 2"].value == "val2"
assert "Option 3" in labels
assert labels["Option 3"].value == "val3"
def test_normalize_choices_json_string_list():
"""Test normalize_choices with a JSON string containing a list."""
json_str = '["Option 1", "Option 2", "Option 3"]'
result = normalize_choices(json_str)
assert len(result) == 3
assert result[0].label == "Option 1"
assert result[1].label == "Option 2"
assert result[2].label == "Option 3"
def test_normalize_choices_json_string_dict():
"""Test normalize_choices with a JSON string containing a dictionary."""
json_str = '{"Option 1": "val1", "Option 2": "val2"}'
result = normalize_choices(json_str)
assert len(result) == 2
labels = {choice.label: choice for choice in result}
assert "Option 1" in labels
assert labels["Option 1"].value == "val1"
assert "Option 2" in labels
assert labels["Option 2"].value == "val2"
def test_normalize_choices_newline_separated_string():
"""Test normalize_choices with a newline-separated string."""
choices = "Option 1\nOption 2\nOption 3"
result = normalize_choices(choices)
assert len(result) == 3
assert result[0].label == "Option 1"
assert result[0].value == "Option 1"
assert result[1].label == "Option 2"
assert result[1].value == "Option 2"
assert result[2].label == "Option 3"
assert result[2].value == "Option 3"
def test_normalize_choices_newline_separated_with_whitespace():
"""Test normalize_choices with newline-separated string containing whitespace."""
choices = " Option 1 \n Option 2 \n Option 3 "
result = normalize_choices(choices)
assert len(result) == 3
assert result[0].label == "Option 1"
assert result[1].label == "Option 2"
assert result[2].label == "Option 3"
def test_normalize_choices_newline_separated_empty_lines():
"""Test normalize_choices with newline-separated string containing empty lines."""
choices = "Option 1\n\nOption 2\n \nOption 3"
result = normalize_choices(choices)
assert len(result) == 3
assert result[0].label == "Option 1"
assert result[1].label == "Option 2"
assert result[2].label == "Option 3"
def test_normalize_choices_single_value():
"""Test normalize_choices with a single value (fallback)."""
result = normalize_choices("Single Option")
assert len(result) == 1
assert result[0].id == "choice_0"
assert result[0].label == "Single Option"
assert result[0].value == "Single Option"
# Test with a number
result = normalize_choices(42)
assert len(result) == 1
assert result[0].label == "42"
assert result[0].value == 42
def test_normalize_choices_list_with_mixed_types():
"""Test normalize_choices with a list containing mixed types."""
choices = [
"String option",
{"id": "dict1", "label": "Dict option", "value": "dict_val"},
UXChoice(id="ux1", label="UXChoice option", value="ux_val"),
123, # Will be stringified
]
result = normalize_choices(choices)
assert len(result) == 4
assert result[0].label == "String option"
assert result[1].id == "dict1"
assert result[1].label == "Dict option"
assert result[2].id == "ux1"
assert result[2].label == "UXChoice option"
assert result[3].label == "123"
assert result[3].value == 123
def test_normalize_choices_empty_list():
"""Test normalize_choices with an empty list."""
result = normalize_choices([])
assert result == []
def test_normalize_choices_empty_dict():
"""Test normalize_choices with an empty dictionary."""
result = normalize_choices({})
assert result == []
def test_normalize_choices_empty_string():
"""Test normalize_choices with an empty string."""
result = normalize_choices("")
assert result == []
def test_normalize_choices_json_string_list_of_dicts():
"""Test normalize_choices with JSON string containing list of dicts."""
json_str = '[{"id": "opt1", "label": "Option 1", "value": "val1"}, {"id": "opt2", "label": "Option 2", "value": "val2"}]'
result = normalize_choices(json_str)
assert len(result) == 2
assert result[0].id == "opt1"
assert result[0].label == "Option 1"
assert result[0].value == "val1"
assert result[1].id == "opt2"
assert result[1].label == "Option 2"
assert result[1].value == "val2"
def test_normalize_choices_dict_with_non_string_keys():
"""Test normalize_choices with dictionary having non-string keys."""
# Note: In Python, True == 1, so True and 1 would collide in a dict
# We'll test with distinct non-string keys
choices = {
1: "value1",
"two": "value2",
3.14: "value3",
}
result = normalize_choices(choices)
assert len(result) == 3
labels = {choice.label: choice for choice in result}
assert "1" in labels
assert labels["1"].value == "value1"
assert "two" in labels
assert labels["two"].value == "value2"
assert "3.14" in labels
assert labels["3.14"].value == "value3"