diff --git a/lang/qet_en.ts b/lang/qet_en.ts
index 5b308acbb..e76b18c19 100644
--- a/lang/qet_en.ts
+++ b/lang/qet_en.ts
@@ -865,7 +865,7 @@ Note: these options DO NOT allow or block auto numberings, only their update pol
Dimensions du folio
- Dimensions of folio
+ Folio size
@@ -996,8 +996,8 @@ Note: these options DO NOT allow or block auto numberings, only their update pol
Modifier la couleur de %n conducteur(s)undo caption
-
-
+ Change the colour of %n conductor
+ Change the colour of %n conductors
@@ -3045,12 +3045,12 @@ The element's display name is edited separately in the element properties.<
Renvoi de folio suivant
- Reference folio following
+ Next folio referenceRenvoi de folio précédent
- Previous reference folio
+ Previous folio reference
@@ -3281,7 +3281,7 @@ The element's display name is edited separately in the element properties.<
Réf.
- Réf.
+ Ref.
@@ -3629,7 +3629,7 @@ The element's display name is edited separately in the element properties.<
Titre du folio
- Title of folio
+ Folio title
@@ -4040,16 +4040,16 @@ By importing this file, you confirm that:
%n élément(s), répartie(s)
- %n element, part
- %n elements, parts
+ %n element, spread
+ %n elements, spreaddans %n dossier(s).
- in %n folder.
- in %n folders.
+ across %n folder.
+ across %n folders.
@@ -4215,7 +4215,7 @@ By importing this file, you confirm that:
Remonter ce folio
- Move up this folio
+ Move this folio up
@@ -4225,17 +4225,17 @@ By importing this file, you confirm that:
Remonter ce folio x10
- Move up this folio x10
+ Move this folio up x10Remonter ce folio x100
- Move up this folio x100
+ Move this folio up x100Remonter ce folio au debut
- Move up this folio to the beginning
+ Move this folio to the beginning
@@ -4381,7 +4381,7 @@ By importing this file, you confirm that:
Titre du folio
- Title of folio
+ Folio title
@@ -4477,7 +4477,7 @@ By importing this file, you confirm that:
Exporter entièrement le folio
- Export fully folio
+ Export the whole folio
@@ -4770,7 +4770,7 @@ that you create. Text and number inputs are
Référence croisé
- Cross reference
+ Cross-reference
@@ -5609,8 +5609,8 @@ Any setting other than “No rounding” may cause rendering errors in the proje
<center>ATTENTION :</center>
il manque %1 lignes afin d'afficher l'intégralité des informations
- <center>ATTENTION :</center>
- lines are missing %1 to display all the informations
+ <center>WARNING:</center>
+ %1 more rows are needed to display all the information
@@ -6195,13 +6195,13 @@ Please use the advanced editor for this.
N° de folio
- N° of folio
+ Folio no.Label de folio
- Label folio
+ Folio label
@@ -6209,7 +6209,7 @@ Please use the advanced editor for this.
Titre de folio
- Title of folio
+ Folio title
@@ -6297,12 +6297,12 @@ Please use the advanced editor for this.
Report de folio
- Folio referencing
+ Folio referenceRéférence croisée (esclave)
- Cross Reference (slave)
+ Cross-reference (slave)
@@ -6364,18 +6364,18 @@ Please use the advanced editor for this.
N° de folio
- N° of folio
+ Folio no.Label de folio
- Label folio
+ Folio labelTitre de folio
- Title of folio
+ Folio title
@@ -6583,12 +6583,12 @@ Do you still want to link this slave contact?
Reports de folio
- Folio referencings
+ Folio referencesRéférences croisées
- Cross References
+ Cross-references
@@ -6816,7 +6816,7 @@ Do you still want to link this slave contact?
N° folio
- N° scheme
+ Folio no.
@@ -7216,7 +7216,7 @@ Voltage / Protocol : %1
Numéro : %1
-Numéro : %1
+Number: %1
@@ -7265,7 +7265,7 @@ Conductor section : %1
Veuillez saisir une formule compatible pour ce potentiel.
Les variables suivantes sont incompatibles :
%sequf_ %seqtf_ %seqhf_ %id %F %M %LM
- The new potential formula contains variables incompatible with the folio reports.
+ The new potential formula contains variables incompatible with folio references.
Please enter a compatible formula for this potential.
The following variables are incompatible:
%sequf_ %seqtf_ %seqhf_ %id %F %M %LM
@@ -8562,7 +8562,7 @@ Available options:
Édite les propriétés du folio (dimensions, informations du cartouche, propriétés des conducteurs...)status bar tip
- Edits the properties of the folio (size, title block informations, conductor properties...)
+ Edits the properties of the folio (size, title block information, conductor properties...)
@@ -9172,12 +9172,12 @@ Hold Ctrl while moving to place freely.
Ajoute une courbe de Bézier sur le folio actuel
- Adds a Bézier curve on the current sheet
+ Adds a Bézier curve to the current folioAjoute un plan de bornier sur le folio actuel
- Add a terminal plan on the current sheet
+ Adds a terminal plan to the current folio
@@ -9273,8 +9273,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b
%n objet(s) remis sur la grille
- %n item placed on the grid
- %n item placed on the grid
+ %n item put back on the grid
+ %n items put back on the grid
@@ -9387,8 +9387,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b
%n élément(s) redessiné(s).
- %n element redesigned.
- %n elements redesigned.
+ %n element redrawn.
+ %n elements redrawn.
@@ -9403,8 +9403,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b
%n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée).
- %n element not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved).
- %n elements not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved).
+ %n element not redrawn: its size, grip point or terminals have changed (a terminal was added, removed or moved).
+ %n elements not redrawn: their size, grip point or terminals have changed (a terminal was added, removed or moved).
@@ -9858,7 +9858,7 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener
<br><b>Erreur</b> :<br>Les reports de folio doivent posséder une seul borne.<br><b>Solution</b> :<br>Verifier que l'élément ne possède qu'une seul borne
- <br> <b> Error </ b>: <br> folio referencings must have a single terminal <br> <b> Solution </ b> :<br> Check that the element has only one terminal
+ <br><b>Error</b>:<br>Folio references must have a single terminal.<br><b>Solution</b>:<br>Check that the element has only one terminal
@@ -9871,8 +9871,8 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener
%n erreur(s)errors
- %n erreur
- %n erreurs
+ %n error
+ %n errors
@@ -11138,8 +11138,8 @@ What do you wish to do ?
%n forme(s)Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence.
- %n forme
- %n formes
+ %n shape
+ %n shapes
@@ -11252,7 +11252,7 @@ What do you wish to do ?
Folio sans titre
- Folio Untitled
+ Untitled folio
@@ -11645,8 +11645,8 @@ the translated name of this folder could not be read, so its folder name is disp
Ajouter %n conducteur(s)add a numbers of conductor one or more
- add %n conductor
- add %n conductors
+ Add %n conductor
+ Add %n conductors
@@ -13849,7 +13849,7 @@ E.g. associating the name "volta" with the value "1745" will
Label de report de folio
- Label of folio referencing
+ Folio reference label
@@ -13861,14 +13861,14 @@ Créer votre propre texte en vous aidant des variables suivantes :
%LM : la localisation
%l : le numéro de ligne
%c : le numéro de colonne
- You can define a custom label for folio reports.
-Create your own text by using the following variables:
-% f: the folio position in the project
-% F: the folio number
-% M: the installation
-% LM: the location
-% l: the line number
-% c: the column number
+ You can define a custom label for folio references.
+Create your own text using the following variables:
+%f: the folio position in the project
+%F: the folio number
+%M: the installation
+%LM: the location
+%l: the row number
+%c: the column number
@@ -14031,7 +14031,7 @@ Create your own text by using the following variables:
Eléments report de folio
- Folio referencings elements
+ Folio reference elements
@@ -17384,7 +17384,7 @@ The other fields are not used.
Référence croisé
- Cross reference
+ Cross-reference
@@ -17673,7 +17673,7 @@ The other fields are not used.
Informations des cartouches
- Title block informations
+ Title block information
@@ -18547,13 +18547,13 @@ associer le nom "volta" et la valeur "1745" remplacera %{vol
%c : le numéro de colonne
%M: Installation
%LM: Localisation
- Create your own text by helping you of the following variables :
-%f : the folio number
-% F: folio label
-% l : the line number
-% c : column number
-% M: Plant
-% LM: Location
+ Create your own text using the following variables:
+%f: the folio number
+%F: the folio label
+%l: the row number
+%c: the column number
+%M: the installation
+%LM: the location
diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md
index 391956b4e..6beb31ff1 100644
--- a/misc/qet-mcp/README.md
+++ b/misc/qet-mcp/README.md
@@ -401,6 +401,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
diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py
index ce11cd467..72cc9c537 100755
--- a/misc/qet-mcp/qet_mcp.py
+++ b/misc/qet-mcp/qet_mcp.py
@@ -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 "
diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py
index 875da3324..7d84d7bde 100644
--- a/misc/qet-mcp/test_qet_mcp.py
+++ b/misc/qet-mcp/test_qet_mcp.py
@@ -30,6 +30,7 @@ import subprocess
import sys
import tempfile
import unittest
+from unittest import mock
import xml.etree.ElementTree as ET
from pathlib import Path
@@ -194,7 +195,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}]))
@@ -224,6 +226,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
@@ -277,7 +300,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
@@ -468,6 +492,80 @@ class EditValidation(unittest.TestCase):
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need))
+ def test_malformed_arguments_are_refused(self):
+ """Each argument kind rejects what it cannot take, before any launch.
+ Found by the mutation audit: forcing any of these checks off went
+ unnoticed."""
+ f = {"op": "add_folio", "id": "f"}
+ cases = [
+ ("indices", {"op": "group_terminals", "strip": 0, "indices": []}),
+ ("indices", {"op": "group_terminals", "strip": 0, "indices": [1, "2"]}),
+ ("indices", {"op": "group_terminals", "strip": 0, "indices": [True]}),
+ ("indices", {"op": "bridge_terminals", "strip": 0, "indices": 3}),
+ ("true or false", {"op": "add_polygon", "folio": 0, "closed": 1,
+ "points": [{"x": 0, "y": 0}, {"x": 1, "y": 1}]}),
+ ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True,
+ "points": [{"x": 0, "y": 0}]}),
+ ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, "points": "xy"}),
+ ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True,
+ "points": [{"x": 0, "y": 0}, {"x": "1", "y": 1}]}),
+ ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True,
+ "points": [{"x": 0, "y": 0}, {"x": True, "y": 1}]}),
+ ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True,
+ "points": [{"x": 0, "y": 0}, [1, 1]]}),
+ ("at least 2 nodes", {"op": "add_path", "folio": 0, "closed": False,
+ "nodes": [{"x": 0, "y": 0}]}),
+ ("corner, smooth or symmetric", {"op": "add_path", "folio": 0, "closed": False,
+ "nodes": [{"x": 0, "y": 0},
+ {"x": 1, "y": 1, "kind": "sharp"}]}),
+ ("inHandle", {"op": "add_path", "folio": 0, "closed": False,
+ "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "inHandle": [0, 0]}]}),
+ ("outHandle", {"op": "add_path", "folio": 0, "closed": False,
+ "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "outHandle": {"x": 0}}]}),
+ ("unknown kind", {"op": "search_and_replace", "kind": "folio", "field": "x",
+ "pattern": "a", "replacement": "b", "regex": False,
+ "case_sensitive": False}),
+ ("unknown conductor field", {"op": "search_and_replace", "kind": "conductor",
+ "field": "label", "pattern": "a", "replacement": "b",
+ "regex": False, "case_sensitive": False}),
+ ("non-empty", {"op": "search_and_replace", "kind": "element_info", "field": "",
+ "pattern": "a", "replacement": "b", "regex": False,
+ "case_sensitive": False}),
+ ("not both", {"op": "delete_conductor", "folio": 0,
+ "conductor": "{11111111-2222-4333-8444-555555555555}",
+ "element": "{11111111-2222-4333-8444-555555555555}"}),
+ ("not both", {"op": "delete_conductor", "folio": 0,
+ "conductor": "{11111111-2222-4333-8444-555555555555}", "terminal": 0}),
+ ("not an object", "add_folio"),
+ ]
+ for message, op in cases:
+ with self.subTest(op=op):
+ with self.assertRaisesRegex(ValueError, message):
+ self.build([f, op])
+
+ def test_valid_shapes_of_those_arguments_pass(self):
+ """The other side: the same kinds accept what they should, so the
+ refusals above are not everything failing."""
+ s = self.build([
+ {"op": "add_polygon", "folio": 0, "closed": False,
+ "points": [{"x": 0, "y": 0}, {"x": 1.5, "y": -2}]},
+ {"op": "add_path", "folio": 0, "closed": True,
+ "nodes": [{"x": 0, "y": 0, "kind": "smooth", "inHandle": {"x": 1, "y": 1}},
+ {"x": 5, "y": 5, "outHandle": {"x": 2, "y": 2}}]},
+ {"op": "add_polygon", "folio": 0, "closed": True,
+ "points": [{"x": 0, "y": 0, "kind": "anything"}, {"x": 1, "y": 1}]},
+ {"op": "search_and_replace", "kind": "conductor", "field": "num", "pattern": "a",
+ "replacement": "b", "regex": True, "case_sensitive": False},
+ {"op": "search_and_replace", "kind": "text", "field": "", "pattern": "a",
+ "replacement": "b", "regex": False, "case_sensitive": True}])
+ self.assertIn("qet.addPolygon(0, [{", s)
+ self.assertIn("qet.searchAndReplace(\"conductor\", \"num\", \"a\", \"b\", true, false)", s)
+ self.assertIn("qet.searchAndReplace(\"text\", \"\", \"a\", \"b\", false, true)", s)
+
+ def test_no_id_stores_nothing(self):
+ s = self.build([{"op": "add_folio"}])
+ self.assertNotIn("R[", s.split("if (missing.length === 0) {")[1])
+
def test_a_string_index_that_is_not_a_uuid_is_refused(self):
for bad in ("3", "{nope}", "11111111-1111"):
with self.subTest(index=bad):
@@ -531,7 +629,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):
@@ -576,6 +675,37 @@ class ResultParsing(unittest.TestCase):
self.assertEqual(parsed["operations"], [])
+ def test_capabilities_notes_and_save(self):
+ """Every field the script reports reaches the result, attached where
+ it belongs. Found by the mutation audit: only the binary tests read
+ these back."""
+ text = "\n".join([
+ self.line(kind="capabilities", missing=["addFolio"]),
+ self.line(kind="op", index=0, op="a", id=None, result=True),
+ self.line(kind="op", index=1, op="b", id=None, result=False),
+ self.line(kind="op_note", index=1, note="why"),
+ self.line(kind="save", result=True, stopped_early=True),
+ self.line(kind="something_newer", result=False)]) # not a save
+ parsed = m._parse_script_output(text)
+ self.assertEqual(set(parsed), {"missing_methods", "operations", "saved", "stopped_early"})
+ self.assertEqual(parsed["missing_methods"], ["addFolio"])
+ self.assertTrue(parsed["saved"])
+ self.assertTrue(parsed["stopped_early"])
+ self.assertNotIn("note", parsed["operations"][0])
+ self.assertEqual(parsed["operations"][1]["note"], "why")
+ # no capabilities line: unknown, not "nothing missing"
+ self.assertIsNone(m._parse_script_output(self.line(kind="save", result=False))["missing_methods"])
+ self.assertEqual(m._parse_script_output(self.line(kind="capabilities", missing=None))
+ ["missing_methods"], [])
+
+ def test_a_line_without_the_marker_is_ignored(self):
+ """Even one that would parse if read from where the marker would be."""
+ almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "save", "result": True})
+ parsed = m._parse_script_output(almost)
+ self.assertIsNone(parsed["saved"])
+ self.assertFalse(parsed["stopped_early"])
+
+
class TerminalOrder(unittest.TestCase):
"""QElectroTech indexes terminals top-to-bottom then left-to-right, not in
file order. Getting it wrong wires the wrong end of a coil with no error;
@@ -719,6 +849,391 @@ class ElementBuild(unittest.TestCase):
self.assertEqual(r["verified"]["names"]["en"], 'Coil "A" & ')
+class ElementFileExact(unittest.TestCase):
+ """Exact answers for the tools that write and read .elmt files and need
+ no QElectroTech. Found by a mutation audit: the existing tests checked a
+ few fields, so dropping any other one from a result, or getting a
+ header number off by one, went unnoticed."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.root = Path(self.tmp.name)
+ m._ELEMENT_INDEX.clear()
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def test_fmt(self):
+ self.assertEqual([m._fmt(v) for v in (True, False, 3, 2.0, 2.5, -0.25, "x")],
+ ["true", "false", "3", "2", "2.5", "-0.25", "x"])
+
+ def test_part_extent_per_kind(self):
+ ext = m._part_extent
+ self.assertEqual(ext("line", {"x1": 1, "y1": 2, "x2": 3, "y2": 4}), [(1, 2), (3, 4)])
+ for kind in ("rect", "ellipse", "arc"):
+ self.assertEqual(ext(kind, {"x": -5, "y": 1, "width": 10, "height": 4}),
+ [(-5, 1), (5, 5)], kind)
+ self.assertEqual(ext("circle", {"x": 2, "y": 3, "diameter": 6}), [(2, 3), (8, 9)])
+ self.assertEqual(ext("polygon", {"points": [[0, 1], [2, 3], [4, 5]]}),
+ [(0, 1), (2, 3), (4, 5)])
+ self.assertEqual(ext("text", {"x": 7, "y": 8, "text": "a"}), [(7, 8)])
+ self.assertEqual(ext("dynamic_text", {"x": 7}), [])
+
+ def test_element_geometry(self):
+ """Box, hotspot and size, worked out by hand: points reach x -10..20
+ and y -15..5; a 5 unit margin, rounded out to tens."""
+ g = m._element_geometry(
+ [{"type": "line", "x1": 0, "y1": 0, "x2": 20, "y2": 0},
+ {"type": "rect", "x": -10, "y": -5, "width": 10, "height": 10}],
+ [{"x": 0, "y": -15, "orientation": "n"}])
+ self.assertEqual(g, {"width": 50, "height": 30, "hotspot_x": 20, "hotspot_y": 20,
+ "bbox": [-10, -15, 20, 5]})
+ # a terminal alone is enough; nothing at all is refused
+ self.assertEqual(m._element_geometry([], [{"x": 0, "y": 0, "orientation": "n"}]),
+ {"width": 20, "height": 20, "hotspot_x": 10, "hotspot_y": 10, "bbox": [0, 0, 0, 0]})
+ with self.assertRaisesRegex(ValueError, "at least one part or terminal"):
+ m._element_geometry([], [])
+
+ def test_part_element(self):
+ E = lambda part: dict(m._part_element(part, "{u}").attrib)
+ self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2.5, 3]]}),
+ {"uuid": "{u}", "x1": "0", "y1": "1", "x2": "2.5", "y2": "3",
+ "closed": "true", "antialias": "true", "style": m.DEFAULT_STYLE})
+ self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2, 3]], "closed": False,
+ "antialias": False, "style": "x"})["closed"], "false")
+ self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1"}),
+ {"uuid": "{u}", "x": "1", "y": "2", "text": "K1", "rotation": "0",
+ "font": "Sans Serif,9,-1,5,50,0,0,0,0,0", "color": "#000000"})
+ self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1", "size": 12,
+ "rotation": 90, "color": "red"})["font"],
+ "Sans Serif,12,-1,5,50,0,0,0,0,0")
+ rect = E({"type": "rect", "x": 0, "y": 0, "width": 4, "height": 2, "antialias": False})
+ self.assertEqual(rect, {"uuid": "{u}", "x": "0", "y": "0", "width": "4", "height": "2",
+ "antialias": "false", "style": m.DEFAULT_STYLE})
+
+ def test_build_writes_and_reports_exactly(self):
+ out = self.root / "k" / "coil.elmt"
+ U = "{11111111-2222-4333-8444-555555555555}"
+ r = m.tool_element_build(
+ str(out), {"fr": "Bobine", "en": "Coil"},
+ [{"type": "line", "x1": 0, "y1": -10, "x2": 0, "y2": 10}],
+ terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "A2"},
+ {"x": 0, "y": -20, "orientation": "n", "name": "A1", "type": "Inner"}],
+ link_type="master", informations={"type": "coil"}, uuid=U)
+ self.assertEqual(set(r), {"ok", "output", "bytes", "width", "height", "hotspot_x",
+ "hotspot_y", "bbox", "terminal_index_order", "part_uuids",
+ "verified"})
+ self.assertTrue(r["ok"])
+ self.assertEqual(r["output"], str(out))
+ self.assertEqual(r["bytes"], out.stat().st_size)
+ self.assertEqual(r["terminal_index_order"], ["A1", "A2"])
+ root = ET.parse(out).getroot()
+ self.assertEqual(dict(root.attrib), {
+ "version": "0.100.0", "type": "element", "link_type": "master",
+ "width": str(r["width"]), "height": str(r["height"]),
+ "hotspot_x": str(r["hotspot_x"]), "hotspot_y": str(r["hotspot_y"])})
+ self.assertEqual(root.find("uuid").get("uuid"), U)
+ self.assertEqual([(n.get("lang"), n.text) for n in root.iter("name")],
+ [("en", "Coil"), ("fr", "Bobine")])
+ self.assertEqual([(k.get("name"), k.text) for k in root.iter("kindInformation")],
+ [("type", "coil")])
+ t2, t1 = root.findall("description/terminal")
+ self.assertEqual({k: v for k, v in t1.attrib.items() if k != "uuid"},
+ {"x": "0", "y": "-20", "orientation": "n", "type": "Inner", "name": "A1"})
+ self.assertEqual(t2.get("type"), "Generic")
+ self.assertRegex(t1.get("uuid"), m._UUID_RE)
+ self.assertNotEqual(t1.get("uuid"), t2.get("uuid"))
+
+ def test_build_refusals(self):
+ out = str(self.root / "x.elmt")
+ line = [{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}]
+ term = [{"x": 0, "y": 0, "orientation": "n"}]
+ for message, kwargs in [
+ ("names must be a non-empty", dict(names={}, parts=line, terminals=term)),
+ ("unknown link_type", dict(names={"en": "a"}, parts=line, terminals=term, link_type="x")),
+ ("parts must be a list", dict(names={"en": "a"}, parts={}, terminals=term)),
+ ("terminal 0 is not an object", dict(names={"en": "a"}, parts=line, terminals=[1])),
+ ("terminal 0 is missing 'orientation'", dict(names={"en": "a"}, parts=line,
+ terminals=[{"x": 0, "y": 0}])),
+ ("orientation is one of", dict(names={"en": "a"}, parts=line,
+ terminals=[{"x": 0, "y": 0, "orientation": "up"}])),
+ ("cannot be connected", dict(names={"en": "a"}, parts=line, terminals=[])),
+ ("same uuid", dict(names={"en": "a"}, terminals=term, parts=[
+ dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}"),
+ dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}")]))]:
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ValueError, message):
+ m.tool_element_build(out, **kwargs)
+
+ def test_element_info_exactly(self):
+ p = self.root / "e.elmt"
+ p.write_text(
+ ''
+ 'E'
+ ' label '
+ ''
+ ''
+ ''
+ '',
+ encoding="utf-8")
+ r = m.tool_element_info(str(p))
+ self.assertEqual(set(r), {"file", "type", "link_type", "width", "height", "names",
+ "terminal_count", "terminals", "terminal_order",
+ "info_fields", "parts", "part_list"})
+ self.assertEqual((r["file"], r["type"], r["link_type"], r["width"], r["height"]),
+ (str(p), "element", "simple", "20", "40"))
+ self.assertEqual(r["terminal_count"], 3)
+ self.assertEqual(r["terminals"][0], {"index": 0, "x": "0", "y": "-10", "orientation": "n",
+ "name": "1", "type": "", "uuid": ""})
+ self.assertEqual(r["terminals"][2], {"index": 2, "x": "0", "y": "10", "orientation": "s",
+ "name": "2", "type": "Generic", "uuid": "{B}"})
+ self.assertEqual(r["info_fields"], ["label"])
+ self.assertEqual(r["parts"], {"line": 1, "terminal": 3, "arc": 1})
+ self.assertEqual(r["part_list"], [{"type": "line", "uuid": "{L}"}, {"type": "arc", "uuid": ""}])
+ self.assertNotIn("undefined", r["terminal_order"])
+ p.write_text(p.read_text().replace('x="5" y="-10"', 'x="0" y="-10"'), encoding="utf-8")
+ self.assertIn("undefined", m.tool_element_info(str(p))["terminal_order"])
+
+ def test_index_entry_exactly(self):
+ f = self.root / "a" / "k.elmt"
+ f.parent.mkdir()
+ f.write_text(''
+ 'Bobine'
+ ' coil '
+ 'x'
+ ''
+ '',
+ encoding="utf-8")
+ (self.root / "a" / "broken.elmt").write_text("", encoding="utf-8")
+ items = m._index_collection(self.root)
+ self.assertEqual(len(items), 1)
+ it = {k: v for k, v in items[0].items() if k != "haystack"}
+ self.assertEqual(it, {"path": "common://a/k.elmt", "file": str(f), "name": "Bobine",
+ "names": {"fr": "Bobine"}, "link_type": "master", "kind": "coil",
+ "terminals": 2, "terminal_names": ["A2", "A1"],
+ "terminal_order_ambiguous": True, "width": "30", "height": "50"})
+ # the index is cached until the collection changes
+ self.assertIs(m._index_collection(self.root), items)
+ self.assertEqual(m._collection_signature(self.root)[0], 3)
+
+ def test_search_ranking_and_limit(self):
+ for rel, name in (("a/1.elmt", "Coil latching"), ("a/2.elmt", "Coil"),
+ ("a/3.elmt", "Remanence coil")):
+ f = self.root / rel
+ f.parent.mkdir(exist_ok=True)
+ f.write_text(f''
+ f'{name}'
+ f'',
+ encoding="utf-8")
+ r = m.tool_element_search(str(self.root), "coil")
+ self.assertEqual([e["name"] for e in r["results"]],
+ ["Coil", "Coil latching", "Remanence coil"])
+ r = m.tool_element_search(str(self.root), "coil", limit=1)
+ self.assertEqual((r["total_matches"], r["returned"]), (3, 1))
+ with self.assertRaisesRegex(ValueError, "limit must be >= 1"):
+ m.tool_element_search(str(self.root), "coil", limit=0)
+ self.assertEqual(m.tool_element_search(str(self.root), "coil", limit=1)["results"][0]["name"],
+ "Coil")
+ self.assertEqual(set(r), {"query", "total_matches", "returned", "indexed", "results"})
+ self.assertEqual((r["query"], r["indexed"]), ("coil", 3))
+ self.assertEqual(r["results"][0]["languages"], ["en"])
+
+ def test_search_puts_the_exact_name_then_names_starting_with_it(self):
+ for n, name in enumerate(("Relay coil", "Coil relay", "Coil relay X", "A coil relay")):
+ f = self.root / f"{n}.elmt"
+ f.write_text(f''
+ f'{name}'
+ f'',
+ encoding="utf-8")
+ r = m.tool_element_search(str(self.root), "coil relay")
+ self.assertEqual([e["name"] for e in r["results"]],
+ ["Coil relay", "Coil relay X", "Relay coil", "A coil relay"])
+
+ def test_search_returns_25_by_default(self):
+ for n in range(26):
+ (self.root / f"{n:02}.elmt").write_text(
+ f'Coil {n}'
+ f''
+ f'', encoding="utf-8")
+ r = m.tool_element_search(str(self.root), "coil")
+ self.assertEqual((r["total_matches"], r["returned"]), (26, 25))
+
+ def test_search_exact_name_wins_a_tie(self):
+ """Same length, same first word: the exact name comes first, not
+ the one whose path sorts first."""
+ for rel, name in (("a.elmt", "Coil-a"), ("z.elmt", "Coil a")):
+ (self.root / rel).write_text(
+ f'{name}'
+ f''
+ f'', encoding="utf-8")
+ r = m.tool_element_search(str(self.root), "coil a")
+ self.assertEqual([e["name"] for e in r["results"]], ["Coil a", "Coil-a"])
+
+ def test_validate_part_refusals(self):
+ ok = lambda part: m._validate_part(0, part)
+ self.assertEqual(ok({"type": "polygon", "points": [[0, 0], [1, 1]]}), "polygon")
+ for message, part in [
+ ("not an object", ["line"]),
+ ("at least two points", {"type": "polygon", "points": [[0, 0]]}),
+ ("at least two points", {"type": "polygon", "points": "ab"}),
+ ("each point is", {"type": "polygon", "points": [[0, 0], [1, 1, 1]]}),
+ ("each point is", {"type": "polygon", "points": [[0, 0], 5]}),
+ ("uuid must look like", {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1,
+ "uuid": "L1"})]:
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ValueError, message):
+ ok(part)
+
+ def test_terminal_order_with_unreadable_coordinates(self):
+ T = lambda **a: ET.Element("terminal", {k: str(v) for k, v in a.items()})
+ ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, y="bad", name="a")])
+ self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
+ ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(x="bad", y=0, name="a")])
+ self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
+ # a missing coordinate counts as 0
+ ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, name="a")])
+ self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
+ ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(y=0, name="a")])
+ self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
+
+
+class CheckAndContinuityAnswers(unittest.TestCase):
+ """qet_check and qet_continuity turn QElectroTech's log lines into their
+ answer. With _run_qet replaced by a stub that returns chosen lines, the
+ whole answer can be checked exactly, without QElectroTech. Found by a
+ mutation audit: the binary tests look at a finding or two, so a wrong
+ summary count, a dropped field or a wrong "passed" went unnoticed."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.qet = Path(self.tmp.name) / "p.qet"
+ self.qet.write_text('',
+ encoding="utf-8")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def stub(self, lines, **extra):
+ out = "\n".join(["noise", m._MARKER + "{not json"] +
+ [m._MARKER + json.dumps(l) for l in lines])
+ return mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": out, "stderr": "", **extra})
+
+ def test_check_answer_exactly(self):
+ rows = [{"label": f"K{i}"} for i in range(12)]
+ lines = [
+ {"kind": "check", "name": "duplicate_master_labels", "rows": rows, "error": ""},
+ {"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""},
+ {"kind": "check", "name": "unnumbered_conductors", "rows": [{"n": 1}, {"n": 2}], "error": ""},
+ {"kind": "check", "name": "duplicate_simple_labels", "rows": [], "error": ""},
+ {"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"},
+ {"kind": "other", "name": "masters_without_manufacturer_reference", "rows": [1]},
+ ]
+ with self.stub(lines):
+ r = m.tool_check("qet", str(self.qet)) # sample defaults to 10
+ C = m.CHECKS
+ self.assertEqual(r, {
+ "ok": False,
+ "summary": {"errors": 1, "warnings": 1, "info": 1, "passed": 1, "check_failures": 2},
+ "findings": [
+ {"check": "duplicate_master_labels", "severity": "error", "count": 12,
+ "note": C["duplicate_master_labels"]["note"], "rows": rows[:10]},
+ {"check": "unlabelled_masters", "severity": "warning", "count": 1,
+ "note": C["unlabelled_masters"]["note"], "rows": [{"x": 1}]},
+ {"check": "unnumbered_conductors", "severity": "info", "count": 2,
+ "note": C["unnumbered_conductors"]["note"], "rows": [{"n": 1}, {"n": 2}]}],
+ "passed": ["duplicate_simple_labels"],
+ "check_failures": [
+ {"check": "empty_folios", "error": "bad SQL"},
+ {"check": "masters_without_manufacturer_reference", "error": "no result came back"}]})
+
+ def test_check_sorts_by_severity_then_name(self):
+ lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [1], "error": ""},
+ {"kind": "check", "name": "empty_folios", "rows": [1], "error": ""}]
+ with self.stub(lines):
+ r = m.tool_check("qet", str(self.qet), checks=["empty_folios", "unlabelled_masters"])
+ self.assertEqual([f["check"] for f in r["findings"]], ["unlabelled_masters", "empty_folios"])
+
+ def test_check_failure_alone_is_not_ok(self):
+ lines = [{"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"}]
+ with self.stub(lines):
+ r = m.tool_check("qet", str(self.qet), checks=["empty_folios"])
+ self.assertFalse(r["ok"])
+
+ def test_check_ignores_a_line_without_the_marker(self):
+ almost = "x" * (len(m._MARKER) - 1) + json.dumps(
+ {"kind": "check", "name": "empty_folios", "rows": [1], "error": ""})
+ with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}):
+ r = m.tool_check("qet", str(self.qet), checks=["empty_folios"])
+ self.assertEqual(r["check_failures"], [{"check": "empty_folios", "error": "no result came back"}])
+
+ def test_check_ok_without_errors_and_sample_zero(self):
+ lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""}]
+ with self.stub(lines):
+ r = m.tool_check("qet", str(self.qet), checks=["unlabelled_masters"], sample=0)
+ self.assertTrue(r["ok"]) # a warning is not a failure
+ self.assertEqual(r["findings"][0]["rows"], [])
+ self.assertEqual(r["findings"][0]["count"], 1)
+ with self.assertRaisesRegex(ValueError, "sample must be >= 0"):
+ m.tool_check("qet", str(self.qet), sample=-1)
+ with self.assertRaisesRegex(ValueError, "no such project"):
+ m.tool_check("qet", str(self.qet) + ".missing")
+
+ def test_check_carries_the_launch_hint(self):
+ with self.stub([], hint="why it did not start"):
+ r = m.tool_check("qet", str(self.qet), checks=["empty_folios"])
+ self.assertFalse(r["ok"])
+ self.assertEqual(r["hint"], "why it did not start")
+
+ def test_continuity_answer_exactly(self):
+ found = [{"severity": "error", "folio": 1, "what": "a"},
+ {"severity": "warning", "folio": 0, "what": "b"},
+ {"severity": "info", "folio": "?", "what": "c"},
+ {"severity": "info", "what": "d"},
+ {"severity": "info", "folio": 1, "what": "e"}]
+ with self.stub([{"kind": "continuity", "findings": found},
+ {"kind": "other", "findings": []}]):
+ r = m.tool_continuity("qet", str(self.qet), folio=1)
+ self.assertEqual((r["finding_count"], r["errors"], r["warnings"], r["info"]), (5, 1, 1, 3))
+ self.assertEqual([f.get("folio_number") for f in r["findings"]], [2, 1, None, None, 2])
+ self.assertNotIn(m._MARKER, r["stdout"])
+ self.assertIn("noise", r["stdout"])
+
+ def test_continuity_passes_the_folio_to_the_script(self):
+ seen = []
+ def fake(binary, args, **kw):
+ seen.append(kw["script"])
+ return {"stdout": m._MARKER + json.dumps({"kind": "continuity", "findings": []}),
+ "stderr": ""}
+ with mock.patch.object(m, "_run_qet", fake):
+ m.tool_continuity("qet", str(self.qet))
+ m.tool_continuity("qet", str(self.qet), folio=1)
+ self.assertIn("qet.checkContinuity(-1)", seen[0])
+ self.assertIn("qet.checkContinuity(1)", seen[1])
+ with self.assertRaisesRegex(ValueError, "no such project"):
+ m.tool_continuity("qet", str(self.qet) + ".missing")
+ almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "continuity", "findings": []})
+ with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}):
+ self.assertFalse(m.tool_continuity("qet", str(self.qet))["ok"])
+
+ def test_continuity_without_findings_is_not_ok(self):
+ with self.stub([]):
+ r = m.tool_continuity("qet", str(self.qet))
+ self.assertFalse(r["ok"])
+ self.assertIn("predate qet.checkContinuity()", r["hint"])
+ with self.stub([], hint="launch failed"):
+ self.assertEqual(m.tool_continuity("qet", str(self.qet))["hint"], "launch failed")
+
+ def test_continuity_folio_bounds(self):
+ for bad in (-1, 2):
+ with self.subTest(folio=bad):
+ with self.assertRaisesRegex(ValueError, f"folio {bad} does not exist"):
+ m.tool_continuity("qet", str(self.qet), folio=bad)
+ with self.stub([{"kind": "continuity", "findings": []}]):
+ self.assertEqual(m.tool_continuity("qet", str(self.qet), folio=0)["finding_count"], 0)
+
+
class ElementSearch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -972,6 +1487,32 @@ class ReadToolContracts(unittest.TestCase):
self.assertEqual(numbered, "1:{E}@0,-4,2--{E}@6,0,1")
self.assertEqual(by_uuid, numbered)
+ def test_conductor_key_same_in_both_forms_in_a_column(self):
+ """Terminals one above the other share their x: a uuid end must be
+ matched on x, y and orientation together, not on any one of them.
+ Found by the mutation audit: an "or" in place of "and" went
+ unnoticed while every test's terminals differed in every coordinate."""
+ root = ET.fromstring(
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ '')
+ numbered, by_uuid = [m._conductor_row(i, c, ix)["key"]
+ for i, c, ix in m._conductors(root)]
+ self.assertEqual(numbered, "1:{E}@0,24,0--{E}@30,24,0")
+ self.assertEqual(by_uuid, numbered)
+
def test_conductor_row_without_an_index(self):
c = ET.fromstring('')
self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8")
@@ -2530,6 +3071,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