qet-mcp: a folio's "$id" keeps naming it after a later insert or removal

The "$id" of an add_folio or insert_folio held the index the folio had
when it was made, so a later insert_folio or remove_folio in the same
qet_edit run shifted it, and ops naming "$id" edited the wrong folio --
the case #1115 fixed for folios given by uuid, left open for these.

The script now also keeps such a folio's uuid (qet.folioUuid()) and
resolves "$id", where an op takes a folio, through qet.folioIndex() at
the moment it is used. On a build without folioUuid() it falls back to
the stored index, as before, and nothing new is required of the binary.
The op's reported result is still the index.

Test: add_folio "$f" (index 1), insert_folio at 0, set_folio_title
"$f": the title lands on the third folio -- on the second without this
change. Two script-generation tests updated for the new expression.
255/255.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-29 11:29:14 +13:00
parent f530b7636e
commit 94b0d8d06d
3 changed files with 63 additions and 4 deletions
+4
View File
@@ -359,6 +359,10 @@ Python, plus the hang guard on `addConductor` and the database refresh in
an index would shift. A folio saved without a uuid shows it empty:
QElectroTech gives it one on load and writes it on the next save, so it
appears after a first `qet_edit`. Needs `qet.folioIndex()` in the build.
The `"$id"` of an `add_folio` or `insert_folio` works the same way: it
keeps naming that folio after a later `insert_folio` or `remove_folio` in
the same run (on a build without `qet.folioUuid()`, it is the index the
folio had when it was made, as before).
- **A conductor can be named by its uuid** (`qet_conductors` reports it):
`set_conductor`, `move_conductor_segment` and `delete_conductor` take
`"conductor": "{uuid}"` in place of `element` + `terminal`, which works
+19 -1
View File
@@ -1430,6 +1430,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"]
FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach",
"indexrev", "folio", "template"]
# The ops that make a folio: their "$id" is an index the next insert_folio
# or remove_folio can shift, so the script also keeps the folio's uuid.
FOLIO_MAKING_OPS = ("add_folio", "insert_folio")
# The ops that address one conductor by element + terminal, and so also
# take "conductor": "{uuid}" (qet_conductors reports each one's uuid).
CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor")
@@ -1469,6 +1473,9 @@ def _build_script(operations: list, output: str) -> str:
JavaScript exception.
"""
refs: set[str] = set()
# "$name"s made by an op that creates a folio: held as the folio's uuid
# too, since a later insert_folio or remove_folio shifts its index.
folio_refs: set[str] = set()
# Resolvers a uuid reference needs; required only when one is used, so
# an index-only edit still runs on a build that predates them.
uuid_methods: set[str] = set()
@@ -1478,6 +1485,7 @@ def _build_script(operations: list, output: str) -> str:
lines = [
"// generated by qet-mcp; do not edit",
"var R = {};", # $name -> value from an earlier op
"var F = {};", # $name -> uuid of a folio an op made
"var missing = [];",
"var need = @NEED@;",
"for (var i = 0; i < need.length; i++) {",
@@ -1550,6 +1558,11 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(
f"operation {op_index} refers to {value!r}, which no earlier "
f"operation defined (set \"id\": {name!r} on the op that creates it)")
if kind == "folio" and name in folio_refs:
# the folio's index now, not when it was made; the stored
# index on a build that cannot report folio uuids
ref = _js(name)
return f"(F[{ref}] ? qet.folioIndex(F[{ref}]) : R[{ref}])"
return f"R[{_js(name)}]"
if kind == "num":
if not isinstance(value, (int, float)) or isinstance(value, bool):
@@ -1752,6 +1765,10 @@ def _build_script(operations: list, output: str) -> str:
if ident is not None:
lines.append(f" R[{_js(ident)}] = v{i};")
refs.add(ident)
if name in FOLIO_MAKING_OPS:
lines.append(f" F[{_js(ident)}] = (typeof qet.folioUuid === 'function' "
f"&& v{i} >= 0) ? qet.folioUuid(v{i}) : '';")
folio_refs.add(ident)
lines.append(
f" qet.log({_js(_MARKER)} + JSON.stringify("
f"{{kind: 'op', index: {i}, op: {_js(name)}, "
@@ -2669,7 +2686,8 @@ TOOLS = [
"qet_project_info call 1 is \"folio\": 0 here. A folio can "
"be given as its uuid instead (qet_project_info lists them), "
"which still names the same folio after an earlier op adds or "
"removes one. A terminal (\"terminal\", \"from_terminal\", "
"removes one; so does the \"$id\" of an add_folio or "
"insert_folio. A terminal (\"terminal\", \"from_terminal\", "
"\"to_terminal\") is given by its index -- qet_element_info "
"lists terminals in index order, top to bottom then left to "
"right -- or by its uuid, which qet_element_info also lists and "
+40 -3
View File
@@ -184,7 +184,8 @@ class EditValidation(unittest.TestCase):
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "add_element", "id": "k", "folio": "$f", "path": "p", "x": 0, "y": 0},
{"op": "delete_element_text", "folio": "$f", "element": "$k", "index": U}])
self.assertIn(f'qet.deleteElementText(R["f"], R["k"], qet.elementTextIndex(R["f"], R["k"], "{U}"))', s)
f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio
self.assertIn(f'qet.deleteElementText({f}, R["k"], qet.elementTextIndex({f}, R["k"], "{U}"))', s)
# the lookups are required only when a uuid is used
self.assertIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": U}]))
self.assertNotIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": 0}]))
@@ -214,6 +215,27 @@ class EditValidation(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "folio index, its uuid"):
self.build([{"op": "set_folio", "folio": "first", "property": "author", "value": "a"}])
def test_folio_ref_follows_the_folio(self):
"""A "$name" made by add_folio or insert_folio is looked up by the
folio's uuid when used as a folio, since a later insert or removal
shifts its index; the stored index is the fallback on a build that
cannot report folio uuids. Used as anything else, it is unchanged."""
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "insert_folio", "id": "g", "position": 0},
{"op": "set_folio_title", "folio": "$f", "title": "t"},
{"op": "set_folio_title", "folio": "$g", "title": "u"}])
self.assertIn("F[\"f\"] = (typeof qet.folioUuid === 'function' && v0 >= 0) "
"? qet.folioUuid(v0) : '';", s)
self.assertIn('qet.setFolioTitle((F["f"] ? qet.folioIndex(F["f"]) : R["f"]), "t")', s)
self.assertIn('qet.setFolioTitle((F["g"] ? qet.folioIndex(F["g"]) : R["g"]), "u")', s)
# not required: an edit still runs on a build without folio uuids
self.assertNotIn('"folioUuid"', s)
# a "$name" from any other op is untouched
s = self.build([{"op": "add_text", "id": "t", "folio": 0, "text": "x", "x": 0, "y": 0},
{"op": "delete_text", "folio": 0, "index": "$t"}])
self.assertIn('R["t"]', s)
self.assertNotIn("F[", s.split("if (missing.length === 0) {")[1])
def test_conductor_by_uuid(self):
"""A conductor named by uuid is turned into one of its ends at run
time; the lookup is required only then, and "conductor" cannot be
@@ -267,7 +289,8 @@ class EditValidation(unittest.TestCase):
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0},
{"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}])
self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, "terminal", R["f"], R["a"], "{T}"))', s)
f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio
self.assertIn(f'qet.deleteConductor({f}, R["a"], qetMcpTerminal(2, "terminal", {f}, R["a"], "{T}"))', s)
s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}])
self.assertIn(f'qetMcpTerminal(0, "terminal", qet.folioIndex("{V}"), "{E}", "{T}")', s)
# an index is unchanged and needs no lookup
@@ -521,7 +544,8 @@ class EditValidation(unittest.TestCase):
script = self.build([{"op": "add_folio", "id": "f"},
{"op": "set_folio_title", "folio": "$f", "title": nasty}])
# the literal must be valid JSON, so JavaScript reads exactly what was sent
literal = re.search(r'setFolioTitle\(R\["f"\], (".*?")\)', script, re.S).group(1)
literal = re.search(r'setFolioTitle\(\(F\["f"\] \? qet\.folioIndex\(F\["f"\]\) : R\["f"\]\), (".*?")\)',
script, re.S).group(1)
self.assertEqual(json.loads(literal), nasty)
def test_edit_refuses_in_place_and_empty(self):
@@ -2355,6 +2379,19 @@ class Integration(unittest.TestCase):
note = r["operations"][1].get("note", "")
self.assertIn("from_terminal: no terminal", note)
self.assertIn("to_terminal: no terminal", note)
def test_folio_ref_survives_an_insert_before_it(self):
"""add_folio names a folio "$f"; inserting another at position 0
moves it from index 1 to 2. "$f" must still name it."""
base = self.sb.new()
r = self.ok(self.sb.edit(base, [
{"op": "add_folio", "id": "f"},
{"op": "insert_folio", "position": 0},
{"op": "set_folio_title", "folio": "$f", "title": "MINE"}]))
self.assertEqual(r["operations"][0]["result"], 1)
titles = [f["title"] for f in m.tool_project_info(r["output"])["folios"]]
self.assertEqual(len(titles), 3)
self.assertEqual(titles[2], "MINE", titles)
self.assertNotIn("MINE", titles[:2])
def test_noop_edit_has_no_conductor_churn(self):
"""Re-saving renumbers the file's terminal ids; the diff must not