diff --git a/TESTING.md b/TESTING.md index 386129a2..932d55ec 100644 --- a/TESTING.md +++ b/TESTING.md @@ -5285,7 +5285,11 @@ workflows without a PromptManager node. It does no file or database I/O. node had Save to library off is `not_saved`, and one whose switch is wired from a node the file cannot show is `not_saved_wired`: nothing is written, no recorded-run fallback, no Needs review item. A resolved pair whose negative's - node is off stores the positive alone (source `opted-out-negative`). + node is off stores the positive alone (source `opted-out-negative`). The + #90 graph walk follows only prompt-like inputs, so the text every later step + names is checked too: a resolved or inspected positive whose path passes a + node that is off (`side_opted_out`), or a legacy answer that is such a + node's own text, makes the file `not_saved`. - **Order.** The default profile decides first, exactly as before (resolved pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved reaches the inspector's resolve (`inspection_resolution` in diff --git a/tests/test_file_prompt.py b/tests/test_file_prompt.py index d0b6e8f9..b609511a 100644 --- a/tests/test_file_prompt.py +++ b/tests/test_file_prompt.py @@ -166,6 +166,23 @@ class TestStepsBeforeResolving(unittest.TestCase): self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9})) +def _off_behind(middle, key): + """A switched-off PromptManagerText reaching the encoder through + ``middle``'s ``key`` input.""" + graph = save_tests.run_graph() + graph["1"] = { + "class_type": "PromptManagerText", + "inputs": {"text": "SECRET OFF TEXT", "save_to_library": False}, + } + graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}} + graph["8"] = { + "class_type": "CLIPTextEncode", + "inputs": {"text": ["7", 0], "clip": ["4", 1]}, + } + graph["3"]["inputs"]["positive"] = ["8", 0] + return graph + + class TestSaveToLibrarySteps(unittest.TestCase): """#90: a PromptManager node with Save to library off stores nothing.""" @@ -184,9 +201,59 @@ class TestSaveToLibrarySteps(unittest.TestCase): def test_step_2_a_wired_switch_is_not_saved_wired(self): graph = save_tests.run_graph(save=["9", 0]) graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} - decision = _decide(_graph(graph)) + lookup = _Lookup("9") + decision = _decide(_graph(graph), lookup=lookup) self.assertEqual(decision.kind, "not_saved_wired") self.assertFalse(decision.try_recorded_run) + self.assertIsNone(decision.review) + self.assertEqual(lookup.calls, 0) + + def test_step_8_1_an_off_node_behind_a_pass_through_is_not_saved(self): + """The #90 graph walk does not follow ``anything`` or ``value``; the + inspector does, so its positive is checked against the switch.""" + for middle, key in (("easy showAnything", "anything"), ("MyNode", "value")): + with self.subTest(middle=middle): + graph = _off_behind(middle, key) + for policy in Policy: + decision = _decide(_graph(graph), policy=policy, lookup=_Lookup()) + self.assertEqual(decision.kind, "not_saved") + self.assertIsNone(decision.text) + + def test_step_8_4_a_legacy_answer_from_an_off_node_is_not_saved(self): + graph = _off_behind("MyNode", "value") + graph["9"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "on text"}} + graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}} + graph["5"]["inputs"]["positive"] = ["9", 0] + decision = _decide(_graph(graph), lookup=_Lookup()) + self.assertEqual(decision.kind, "not_saved") + self.assertIsNone(decision.text) + + def test_steps_5_and_7_check_the_resolved_positive_too(self): + """A resolved positive through an off node the #90 walk missed.""" + cases = { + "pair": save_tests.run_graph(save=False), + "negative off": save_tests.run_graph(save=False, negative_save=False), + } + for name, graph in cases.items(): + with ( + self.subTest(name=name), + mock.patch.object( + file_prompt, "metadata_opted_out", return_value=False + ), + ): + self.assertEqual(_decide(_graph(graph)).kind, "not_saved") + + def test_step_5_a_junk_positive_is_never_stored(self): + """Pinned: the positive alone still passes the junk gate, and a blank + positive (never storable) stores nothing either.""" + graph = save_tests.run_graph(save=True, negative_save=False) + graph["1"]["inputs"]["text"] = "2048" + decision = _decide(_graph(graph)) + self.assertEqual((decision.kind, decision.source), ("never", "junk:number")) + self.assertEqual(decision.unlinked_status, "found") + graph["1"]["inputs"]["text"] = "" + decision = _decide(_graph(graph)) + self.assertEqual((decision.kind, decision.text), ("never", None)) def test_step_2_a_workflow_only_opt_out_comes_before_step_3(self): workflow = { diff --git a/tests/test_pair_scan.py b/tests/test_pair_scan.py index 0640a09e..3f91f41d 100644 --- a/tests/test_pair_scan.py +++ b/tests/test_pair_scan.py @@ -590,3 +590,21 @@ class TestScanDecision(AdminAPITestCase): meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))} self.assertEqual(self.api._ingest_scanned_file(path, meta), "added") self.assertEqual(calls, [1]) + + def test_an_off_node_behind_a_pass_through_stores_nothing(self): + """#90 regression guard for the scan (main stored nothing here).""" + from tests.test_save_to_library import run_graph + + g = run_graph(save=False) + g["1"]["class_type"] = "PromptManagerText" + g["7"] = {"class_type": "easy showAnything", "inputs": {"anything": ["1", 0]}} + g["8"] = { + "class_type": "CLIPTextEncode", + "inputs": {"text": ["7", 0], "clip": ["4", 1]}, + } + g["3"]["inputs"]["positive"] = ["8", 0] + path = self._path("off.png") + meta = {"prompt": json.dumps(g)} + self.assertEqual(self.api._ingest_scanned_file(path, meta), "not_saved") + self.assertEqual(self.api.db.get_recent_prompts()["prompts"], []) + self.assertEqual(self._links(path), []) diff --git a/utils/file_prompt.py b/utils/file_prompt.py index 4b652650..64f60dec 100644 --- a/utils/file_prompt.py +++ b/utils/file_prompt.py @@ -28,7 +28,12 @@ from .generation_params import ( extract_generation_params, parse_page_json, ) -from .prompt_graph import metadata_opted_out, side_opted_out +from .prompt_graph import ( + PROMPT_MANAGER_TYPES, + metadata_opted_out, + save_setting, + side_opted_out, +) from .prompt_inspector import inspection_resolution from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt from .review_capture import reviewable @@ -127,6 +132,12 @@ def decide_file_prompt( return Decision(NEVER, canonical=canonical, try_recorded_run=True) _with_size(canonical, file_size) resolution = resolve_prompt_pair(canonical) + if resolution["state"] == "resolved" and side_opted_out( + canonical.get("prompt"), resolution["positive"] + ): + # The positive passes a node that is off, though the #90 graph walk + # did not reach it (steps 5 and 7) + return Decision(NOT_SAVED, canonical=canonical) if resolution["state"] == "resolved" and side_opted_out( canonical.get("prompt"), resolution["negative"] ): @@ -187,6 +198,11 @@ def _undecided_graph(found: _File) -> Decision: found.metadata, found.file_name, saver, name_anchor=False ) text = _inspected_text(inspected.resolution) + if text is not None and side_opted_out( + found.canonical["prompt"], inspected.resolution["positive"] + ): + # The inspector follows pass-throughs the #90 graph walk does not + return Decision(NOT_SAVED, canonical=found.canonical) if text is not None: source = "recorded-run" if inspected.by_record else "inspector" return _decision(found, POSITIVE, text, source) @@ -196,6 +212,9 @@ def _undecided_graph(found: _File) -> Decision: if not inspected.malformed and inspected.resolution["state"] == "ambiguous": return _never(found, _unlinked(found)) text = graph_prompt_text(found.metadata) + if text is not None and _off_text(found.canonical["prompt"], text): + # The legacy answer is a switched-off node's own text (#90) + return Decision(NOT_SAVED, canonical=found.canonical) if text is not None and _joined_text(found.canonical["prompt"], text): # The legacy parser joins prepend + text + append: the text a # PromptManager node stores cannot be told from it (#343). @@ -223,6 +242,26 @@ def _sampler_less(found: _File, graph: Any) -> Decision: _UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser") +def _off_text(graph: Any, text: str) -> bool: + """Whether ``text`` is the literal text of a PromptManager-family node + whose Save to library is not on.""" + if not isinstance(graph, dict): + return False + wanted = text.strip() + for node_id, node in graph.items(): + if not isinstance(node, dict) or node.get("class_type") not in ( + PROMPT_MANAGER_TYPES + ): + continue + inputs = node.get("inputs") + literal = inputs.get("text") if isinstance(inputs, dict) else None + if not isinstance(literal, str) or literal.strip() != wanted: + continue + if save_setting(graph, node_id) is not True: + return True + return False + + def _joined_text(graph: Any, text: str) -> bool: """Whether ``text`` may hold a PromptManager node's prepend or append.