Merge branch 'master' into feat/qet-mcp-edit-folio-uuid

This commit is contained in:
Laurent Trinques
2026-09-28 20:53:51 +02:00
committed by GitHub
23 changed files with 1398 additions and 24 deletions
+12
View File
@@ -296,6 +296,18 @@ 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.
- **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
where two conductors meet at a terminal. It is turned at run time into an
end whose terminal carries only that conductor; where both of its ends
are shared the op fails and its `note` says why. Needs
`qet.conductorEnds()` in the build. A uuid names one wire, but
`set_conductor` still changes the whole potential, the same as by
terminal. A project saved before conductors carried a uuid has none in
the file: QElectroTech makes a new one on every load and does not save
it, so `qet_conductors` reports `uuid` as empty and those conductors are
named by `element` + `terminal` until wire uuids last (#1103).
- **`qet_export` isolates its launch.** SingleApplication keys its socket
on `applicationFilePath()`, so a second launch of the same binary path
forwards its request to an already-running instance and returns *that*
+72 -3
View File
@@ -1355,6 +1355,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"]
FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach",
"indexrev", "folio", "template"]
# 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")
# Accepted by set_conductor. The names are the project file's own, so what
# a script sets is what qet_conductors reports back.
CONDUCTOR_PROPERTIES = ["num", "formula", "function", "bus", "cable",
@@ -1406,6 +1410,35 @@ def _build_script(operations: list, output: str) -> str:
f"qet.log({_js(_MARKER)} + JSON.stringify("
"{kind: 'capabilities', missing: missing}));",
"var stop = false;",
# A conductor named by uuid is turned into one of its ends, as the
# conductor calls take it: an end whose terminal carries no other
# conductor, so the call cannot pick the wrong one. null, with the
# reason logged, if it is not on the folio or both ends are shared.
"function qetMcpConductorEnd(index, folio, uuid) {",
" var ends = qet.conductorEnds(folio, uuid);",
" var why = 'no conductor ' + uuid + ' on folio ' + folio;",
" if (ends && ends.length === 2) {",
" var lines = qet.conductors(folio);",
" for (var k = 0; k < 2; k++) {",
" if (ends[k] === '?') continue;",
" var n = 0;",
" for (var j = 0; j < lines.length; j++) {",
" var p = lines[j].split(' : ')[0].split(' -- ');",
" if (p[0] === ends[k] || p[1] === ends[k]) n++;",
" }",
" if (n === 1) {",
" var m = ends[k].split(' terminal ');",
" return {element: m[0], terminal: parseInt(m[1], 10)};",
" }",
" }",
" why = 'conductor ' + uuid + ' shares both of its terminals with other '",
" + 'conductors; the conductor calls address one by a terminal carrying '",
" + 'only it';",
" }",
f" qet.log({_js(_MARKER)} + JSON.stringify("
"{kind: 'op_note', index: index, note: why}));",
" return null;",
"}",
"if (missing.length === 0) {",
]
@@ -1572,6 +1605,25 @@ def _build_script(operations: list, output: str) -> str:
if name == "add_shape" and op.get("shape") not in SHAPES:
raise ValueError(f"operation {i}: unknown shape {op.get('shape')!r}; "
f"expected one of {', '.join(SHAPES)}")
# The conductor ops take "conductor": "{uuid}" in place of element +
# terminal: a uuid names one conductor for good, where a terminal
# can carry several.
conductor_js = None
if name in CONDUCTOR_UUID_OPS and "conductor" in op:
if "element" in op or "terminal" in op:
raise ValueError(f"operation {i} ({name}): give either \"conductor\" "
f"(its uuid) or \"element\" + \"terminal\", not both")
if op["conductor"] == "":
raise ValueError(f"operation {i}: \"conductor\" is empty -- a conductor "
f"from a project saved before conductors carried a uuid "
f"has none in the file; name it by \"element\" + "
f"\"terminal\" instead")
if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]):
raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, "
f"got {op['conductor']!r}")
conductor_js = _js(op["conductor"])
uuid_methods.add("conductorEnds")
op = {**op, "element": "{00000000-0000-0000-0000-000000000000}", "terminal": 0}
args = []
for key, kind in spec:
if key not in op:
@@ -1579,6 +1631,8 @@ def _build_script(operations: list, output: str) -> str:
folio_js = args[0] if args else "0"
element_js = args[1] if len(args) > 1 else None
args.append(ref_or(op[key], kind, i, key))
if conductor_js is not None:
args[1], args[2] = f"e{i}.element", f"e{i}.terminal"
ident = op.get("id")
if ident is not None:
@@ -1590,6 +1644,9 @@ def _build_script(operations: list, output: str) -> str:
call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})"
lines.append(" if (!stop) {")
if conductor_js is not None:
lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});")
call = f"(e{i} ? {call} : false)"
lines.append(f" var v{i} = {call};")
if ident is not None:
lines.append(f" R[{_js(ident)}] = v{i};")
@@ -1635,7 +1692,7 @@ def _parse_script_output(text: str) -> dict:
the cost is nothing and the failure it prevents is silent (an edit that
worked, reported as having run no operations at all, which is what the
first version of this tool did)."""
caps, ops, saved, stopped = None, [], None, False
caps, ops, saved, stopped, notes = None, [], None, False, {}
for line in text.splitlines():
idx = line.find(_MARKER)
if idx < 0:
@@ -1654,9 +1711,14 @@ def _parse_script_output(text: str) -> dict:
rec["succeeded"] = not (r is None or r is False or r == "" or r == [] or r == {} or
(isinstance(r, int) and not isinstance(r, bool) and r == -1))
ops.append(rec)
elif rec.get("kind") == "op_note":
notes[rec.get("index")] = rec.get("note")
elif rec.get("kind") == "save":
saved = bool(rec.get("result"))
stopped = bool(rec.get("stopped_early"))
for rec in ops:
if rec.get("index") in notes:
rec["note"] = notes[rec["index"]]
return {"missing_methods": caps, "operations": ops, "saved": saved,
"stopped_early": stopped}
@@ -2507,8 +2569,15 @@ TOOLS = [
"index in the element definition; qet_element_info lists them. "
"set_conductor addresses a conductor as the one on a given "
"terminal and applies the change to its whole electrical "
"potential, so name a terminal carrying exactly one conductor; "
"its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) +
"potential, so name a terminal carrying exactly one conductor, "
"or give \"conductor\": its uuid from qet_conductors in place "
"of \"element\" + \"terminal\" (set_conductor, "
"move_conductor_segment and delete_conductor all accept it; it "
"needs one of the conductor's two terminals to carry only it, "
"and a conductor qet_conductors lists with an empty uuid has "
"none to give). A uuid names one wire, but set_conductor still "
"changes its whole potential, as it does by terminal. "
"set_conductor's \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) +
". move_conductor_segment reroutes the drawn path itself rather "
"than a property of the potential -- addressed the same way (a "
"terminal carrying exactly one conductor), plus a segment index "
+62
View File
@@ -213,6 +213,34 @@ class EditValidation(unittest.TestCase):
self.build([{"op": "set_folio", "folio": "", "property": "author", "value": "a"}])
with self.assertRaisesRegex(ValueError, "folio index, its uuid"):
self.build([{"op": "set_folio", "folio": "first", "property": "author", "value": "a"}])
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
given alongside element + terminal."""
U = "{11111111-2222-4333-8444-555555555555}"
s = self.build([{"op": "delete_conductor", "folio": 1, "conductor": U}])
self.assertIn(f'var e0 = qetMcpConductorEnd(0, 1, "{U}");', s)
self.assertIn("(e0 ? qet.deleteConductor(1, e0.element, e0.terminal) : false)", s)
self.assertIn('"conductorEnds"', s)
s = self.build([{"op": "set_conductor", "folio": 0, "conductor": U,
"property": "num", "value": "W1"}])
self.assertIn('qet.setConductorProperty(0, e0.element, e0.terminal, "num", "W1")', s)
s = self.build([{"op": "move_conductor_segment", "folio": 2, "conductor": U,
"segment": 1, "dx": 10, "dy": 0}])
self.assertIn(f'var e0 = qetMcpConductorEnd(0, 2, "{U}");', s)
self.assertIn("(e0 ? qet.moveConductorSegment(2, e0.element, e0.terminal, 1, 10, 0) : false)", s)
# an end whose terminal or element is missing is never picked
self.assertIn("if (ends[k] === '?') continue;", s)
self.assertNotIn('"conductorEnds"', self.build(
[{"op": "delete_conductor", "folio": 0, "element": U, "terminal": 0}]))
with self.assertRaisesRegex(ValueError, "not both"):
self.build([{"op": "delete_conductor", "folio": 0, "conductor": U,
"element": U, "terminal": 0}])
with self.assertRaisesRegex(ValueError, "must be a conductor uuid"):
self.build([{"op": "delete_conductor", "folio": 0, "conductor": "W1"}])
with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"):
self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}])
def test_every_op_generates_a_script(self):
# one minimal valid instance of every op
@@ -2399,6 +2427,40 @@ class Integration(unittest.TestCase):
r = self.ok(self.sb.edit(base, ops))
self.assertEqual([c["num"] for c in m.tool_conductors(r["output"])["conductors"]], ["W7"])
def _hub(self):
"""e0's terminal 0 carries two conductors, to e1 and to e2. Returns
the saved project and the two conductors' uuids."""
base = self.sb.new()
r = self.ok(self.sb.edit(base, [
{"op": "add_folio", "id": "f"},
*[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100}
for i in range(3)],
{"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0},
{"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}]))
uuids = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]]
self.assertEqual(len(uuids), 2)
self.assertTrue(all(uuids), "new conductors carry a saved uuid")
return r["output"], uuids
def test_conductor_by_uuid_where_two_meet_at_a_terminal(self):
"""The terminal e0/0 carries both conductors, which element + terminal
cannot name; each uuid names one, whichever it is."""
project, uuids = self._hub()
for gone, kept in ((uuids[0], uuids[1]), (uuids[1], uuids[0])):
with self.subTest(deleted=gone):
r = self.ok(self.sb.edit(project, [
{"op": "delete_conductor", "folio": 1, "conductor": gone}], out="out2.qet"))
left = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]]
self.assertEqual(left, [kept])
def test_unknown_conductor_uuid_is_reported(self):
project, _ = self._hub()
r = self.sb.edit(project, [{"op": "delete_conductor", "folio": 1,
"conductor": "{11111111-2222-4333-8444-555555555555}"}],
out="out2.qet")
self.assertFalse(r["ok"])
self.assertIn("no conductor", r["operations"][0].get("note", ""))
# ---- cross references, strips, folios ----
def test_cross_reference_across_folios(self):
+70
View File
@@ -437,6 +437,24 @@ void Diagram::mousePressEvent(QGraphicsSceneMouseEvent *event)
}
rememberSelection();
//Clicking again on a member of a group that is selected whole picks
//that member out, to edit it on its own (discussion #1070): noted
//here, decided on release, since a drag must still move the group.
//Ctrl keeps its usual meaning.
m_member_to_pick.clear();
if (event->button() == Qt::LeftButton
&& !event->modifiers().testFlag(Qt::ControlModifier)) {
QTransform view_transform;
if (event->widget()) {
if (auto view = qobject_cast<QGraphicsView *>(event->widget()->parentWidget())) {
view_transform = view->transform();
}
}
if (QGraphicsItem *member = ItemGroups::memberToPick(
itemAt(event->scenePos(), view_transform))) {
m_member_to_pick = member->toGraphicsObject();
}
}
QGraphicsScene::mousePressEvent(event);
completeGroupSelection();
}
@@ -477,6 +495,19 @@ void Diagram::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
}
QGraphicsScene::mouseReleaseEvent(event);
//A click that did not drag, on a member of a group selected whole:
//Qt has left only that member selected, and it stays so.
QGraphicsObject *picked = m_member_to_pick.data();
m_member_to_pick.clear();
if (picked
&& (event->screenPos() - event->buttonDownScreenPos(Qt::LeftButton)).manhattanLength()
< QApplication::startDragDistance()
&& selectedItems() == QList<QGraphicsItem *>{picked}) {
rememberSelection();
return;
}
//A click on an already selected item changes the selection on
//release, not on press (Ctrl toggles it, a plain click keeps only it).
completeGroupSelection();
@@ -1676,6 +1707,23 @@ bool Diagram::fromXml(QDomElement &document,
delete nvel_elmt;
qDebug() << QStringLiteral("Diagram::fromXml() : Le chargement des parametres d'un element a echoue");
} else {
//A symbol saved without a uuid got a random one from
//Element::fromXml(): a different identity on every load,
//written out on the next save. Derive it instead from what
//the symbol is and where it sits on its folio -- never from
//the folio's index, so inserting or moving a folio does not
//change it. Only for a folio being loaded: a paste renews
//uuids anyway.
if (consider_informations && m_project
&& QUuid(element_xml.attribute(QStringLiteral("uuid"))).isNull()) {
nvel_elmt->setUuid(m_project->derivedItemUuid(
QStringLiteral("element"),
QStringList{type_id,
element_xml.attribute(QStringLiteral("x")),
element_xml.attribute(QStringLiteral("y")),
element_xml.attribute(QStringLiteral("orientation"))}
.join(QLatin1Char('\n'))));
}
ItemGroups::setGroup(nvel_elmt, ItemGroups::read(element_xml));
added_elements << nvel_elmt;
}
@@ -1808,6 +1856,28 @@ bool Diagram::fromXml(QDomElement &document,
{
addItem(c);
c -> fromXml(f);
//A wire saved without a uuid got a random one that was
//never saved (#754), so it had no identity from one
//session to the next. Derive it from what it connects:
//the symbol and terminal at each end, sorted so the
//direction it was drawn in does not matter. Never its
//place in the file or its folio's index, so inserting or
//moving a folio, or saving the wires in another order,
//does not change it. It is saved from now on, so
//re-connecting the wire later keeps it; derivedItemUuid()
//never hands out a uuid the file already carries, so a
//wire later drawn on the ends it left gets another one.
if (consider_informations && m_project
&& QUuid(f.attribute(QStringLiteral("uuid"))).isNull()) {
auto end = [](const Terminal *t) {
return t->parentElement()->uuid().toString()
+ QLatin1Char('/') + t->stableUuid().toString();
};
QStringList ends{end(p1), end(p2)};
ends.sort();
c->setUuid(m_project->derivedItemUuid(QStringLiteral("conductor"),
ends.join(QLatin1Char('\n'))));
}
added_conductors << c;
}
else
+3
View File
@@ -142,6 +142,9 @@ class Diagram : public QGraphicsScene
//Selection before the current click, see completeGroupSelection()
QList<QPointer<QGraphicsObject>> m_previous_selection;
void rememberSelection();
//Member of a wholly selected group under the current click, which
//the click picks out on its own if it ends without a drag
QPointer<QGraphicsObject> m_member_to_pick;
bool uuidUsedByOtherDiagram(const QUuid &uuid) const;
QUuid derivedUuid(const QDomElement &root, const QString &reason) const;
+25 -11
View File
@@ -144,6 +144,27 @@ bool DiagramContext::operator!=(const DiagramContext &dc) const
return(!(*this == dc));
}
namespace {
/**
The value as it is saved, and so as it is read back: stray leading and
trailing whitespace around real content trimmed, but not a value that IS
whitespace -- unconditionally trimming an all-whitespace string collapses
it to "", which is silently indistinguishable from a value that was never
set. A title-block custom variable set to a single space -- a workaround
for #973, where an unset variable renders as its own literal placeholder
-- would otherwise vanish on the very next save.
Applied when reading as well as when writing, so that what is in memory
after a load is what the next save writes: a label shown from an
untrimmed value would otherwise keep its spaces on screen and in its
displayed copy until the project was saved and opened again, and a
just-saved project would change on its second save.
*/
QString storedValue(const QString &raw)
{
return raw.trimmed().isEmpty() ? raw : raw.trimmed();
}
} // namespace
/**
Export this context properties under the \a e XML element, using tags
named \a tag_name (defaults to "property").
@@ -161,15 +182,7 @@ void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const
property.removeAttribute("name");
property.setAttribute("show", m_content_show[key]);
property.setAttribute("name", key);
// Trim stray leading/trailing whitespace around real content, but
// not a value that IS whitespace: unconditionally trimming an
// all-whitespace string collapses it to "", which is silently
// indistinguishable from a value that was never set. A title-block
// custom variable set to a single space -- a workaround for #973,
// where an unset variable renders as its own literal placeholder --
// would otherwise vanish on the very next save.
const QString stored = raw.trimmed().isEmpty() ? raw : raw.trimmed();
QDomText value = e.ownerDocument().createTextNode(stored);
QDomText value = e.ownerDocument().createTextNode(storedValue(raw));
property.appendChild(value);
e.appendChild(property);
}
@@ -182,7 +195,7 @@ void DiagramContext::toXml(QDomElement &e, const QString &tag_name) const
void DiagramContext::fromXml(const QDomElement &e, const QString &tag_name) {
foreach (QDomElement property, QET::findInDomElement(e, tag_name)) {
if (!property.hasAttribute("name")) continue;
addValue(property.attribute("name"), QVariant(property.text()));
addValue(property.attribute("name"), QVariant(storedValue(property.text())));
m_content_show.insert(property.attribute("name"), property.attribute("show", "1").toInt());
}
}
@@ -198,7 +211,8 @@ void DiagramContext::fromXml(const pugi::xml_node &dom_element, const QString &t
{
for(auto node = dom_element.child(tag_name.toStdString().c_str()) ; node ; node = node.next_sibling(tag_name.toStdString().c_str()))
{
addValue(node.attribute("name").as_string(), QVariant(node.text().as_string()));
addValue(node.attribute("name").as_string(),
QVariant(storedValue(QString::fromUtf8(node.text().as_string()))));
m_content_show.insert(node.attribute("name").as_string(), node.attribute("show").empty()? 1 : node.attribute("show").as_int());
}
}
+72
View File
@@ -62,6 +62,47 @@ QUuid ItemGroups::read(const QDomElement &xml)
return QUuid(xml.attribute(QString::fromLatin1(xml_attribute)));
}
/**
@return @a item, or its nearest ancestor, that belongs to a group -- a
click on a symbol's own text hits the text, but the symbol is the member
-- or nullptr if none does.
*/
QGraphicsItem *ItemGroups::groupedItem(QGraphicsItem *item)
{
for (; item; item = item->parentItem()) {
if (!groupOf(item).isNull()) {
return item;
}
}
return nullptr;
}
/**
@return the member a click on @a hit may pick out on its own: the grouped
item hit, when every member of its group is selected already. A first
click selects the whole group; a second click, on a member of the group
it selected, is how the user asks for that member alone (discussion
#1070). nullptr when the click is not that.
*/
QGraphicsItem *ItemGroups::memberToPick(QGraphicsItem *hit)
{
QGraphicsItem *member = groupedItem(hit);
if (!member || !member->isSelected() || !member->scene()) {
return nullptr;
}
const QUuid group = groupOf(member);
int members = 0;
for (QGraphicsItem *item : member->scene()->items()) {
if (groupOf(item) == group) {
if (!item->isSelected()) {
return nullptr;
}
++members;
}
}
return members > 1 ? member : nullptr;
}
/**
Make the selection of @a scene whole groups again after it changed.
A group with a selected member is selected entirely, except when the
@@ -122,3 +163,34 @@ bool ItemGroups::completeSelection(QGraphicsScene *scene,
}
return changed;
}
/**
@return the group @a selected is exactly, whole -- every item in it
belongs to that group and every member of the group is in it -- or a null
uuid. Rotating such a selection turns the group as one piece rather than
each member in place (discussion #1070). A member picked out on its own
is not a whole group, and turns in place.
@param selected : the selected items that can be members (the caller
leaves out wires, which follow their symbols)
*/
QUuid ItemGroups::soleWholeGroup(const QList<QGraphicsItem *> &selected)
{
if (selected.isEmpty() || !selected.first()->scene()) {
return QUuid();
}
const QUuid group = groupOf(selected.first());
if (group.isNull()) {
return QUuid();
}
for (QGraphicsItem *item : selected) {
if (groupOf(item) != group) {
return QUuid();
}
}
for (QGraphicsItem *item : selected.first()->scene()->items()) {
if (groupOf(item) == group && !item->isSelected()) {
return QUuid();
}
}
return group;
}
+5
View File
@@ -52,9 +52,14 @@ namespace ItemGroups
void write(QDomElement &xml, const QGraphicsItem *item);
QUuid read(const QDomElement &xml);
QGraphicsItem *groupedItem(QGraphicsItem *item);
QGraphicsItem *memberToPick(QGraphicsItem *hit);
bool completeSelection(QGraphicsScene *scene,
const QList<QGraphicsItem *> &previous,
bool toggling);
QUuid soleWholeGroup(const QList<QGraphicsItem *> &selected);
}
#endif // ITEMGROUPS_H
+12 -1
View File
@@ -25,6 +25,7 @@
#include "ElementsCollection/elementpickerpopup.h"
#include "shortcutbarsettings.h"
#include "qetgraphicsitem/conductor.h"
#include "itemgroups.h"
#include "commandsearchpopup.h"
#include "QWidgetAnimation/qwidgetanimation.h"
#include "autoNum/ui/autonumberingdockwidget.h"
@@ -2078,7 +2079,17 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action)
}
else if (value == "rotate_selection")
{
RotateSelectionCommand *c = new RotateSelectionCommand(diagram);
//A selection that is exactly one whole group turns as one piece,
//as "Pivoter le groupe" does, rather than each member in place
//(discussion #1070). Wires follow their symbols either way.
QList<QGraphicsItem *> members;
for (QGraphicsItem *item : diagram->selectedItems()) {
if (item->type() != Conductor::Type) {
members << item;
}
}
const bool whole_group = !ItemGroups::soleWholeGroup(members).isNull();
RotateSelectionCommand *c = new RotateSelectionCommand(diagram, 90, nullptr, whole_group);
if(c->isValid())
diagram->undoStack().push(c);
}
+1
View File
@@ -80,6 +80,7 @@ class Conductor : public QGraphicsObject
ConductorTextItem *textItem() const;
QUuid uuid() const {return m_uuid;}
void newUuid() {m_uuid = QUuid::createUuid(); m_persist_uuid = true;} //create new uuid for this conductor
void setUuid(const QUuid &uuid) {m_uuid = uuid; m_persist_uuid = true;} //saved from now on
void updatePath(const QRectF & = QRectF());
//This method do nothing, it's only made to be used with Q_PROPERTY
+5 -1
View File
@@ -1033,7 +1033,11 @@ QDomElement Element::toXml(
QDomElement infos =
document.createElement(QStringLiteral("elementInformations"));
m_data.m_informations.toXml(infos, QStringLiteral("elementInformation"));
element.appendChild(infos);
//toXml() skips empty values: an element whose information is
//all empty would otherwise be written an empty block, which the
//next load reads as no information and the next save drops.
if (infos.hasChildNodes())
element.appendChild(infos);
}
//Save override properties (For now, only used when the element is a terminal)
+1
View File
@@ -245,6 +245,7 @@ class Element : public QetGraphicsItem
QString linkTypeToString() const;
void newUuid() {m_uuid = QUuid::createUuid();} //create new uuid for this element
void setUuid(const QUuid &uuid) {m_uuid = uuid;}
protected:
void drawAxes(QPainter *, const QStyleOptionGraphicsItem *);
+48
View File
@@ -275,6 +275,42 @@ QUuid QETProject::uuid() const
return m_uuid;
}
/**
@brief QETProject::derivedItemUuid
A uuid for an item of this project that was saved without one, the same
on every load of the same file.
@p key describes the item by what it is, never by its place in the file
or its folio's index: inserting or moving a folio must not change it.
Items with the same @p kind and @p key anywhere in the project (a copied
folio, two identical symbols stacked on one spot) are told apart by a
counter, in load order among those items alone.
A derived uuid is never one the file already carries: an item saved with
a derived uuid and then moved or re-connected keeps it, so a newcomer
later taking its old place or ends would otherwise derive the same one.
The file's saved uuids are collected before any folio loads
(readDiagramsXml()), so the result still depends on the file alone.
uuids are unique within one project; copies of a project share them, as
they share every saved uuid. Anything bringing items in from another
project must renew them, as paste does.
@return a UUID v5, which cannot collide with the v4 uuids given to new
items
*/
QUuid QETProject::derivedItemUuid(const QString &kind, const QString &key)
{
static const QUuid derived_ns(QStringLiteral("{7d1e9c3a-5b2f-4e8a-9c61-2f4b8d0e6a17}"));
const QString full = kind + QLatin1Char('\n') + key;
int &n = m_derived_uuid_keys[full];
QUuid uuid;
do {
uuid = QUuid::createUuidV5(derived_ns,
n ? full + QLatin1Char('\n') + QString::number(n) : full);
++n;
} while (m_saved_item_uuids.contains(uuid));
return uuid;
}
/**
@brief QETProject::init
*/
@@ -1888,6 +1924,18 @@ void QETProject::readDiagramsXml(QDomDocument &xml_project)
//Search the diagrams in the project
QDomNodeList diagram_nodes = xml_project.elementsByTagName(QStringLiteral("diagram"));
//Every symbol and wire uuid the file already carries, on any folio,
//before a folio derives one for an item saved without: see
//derivedItemUuid().
for (const QString &tag : {QStringLiteral("element"), QStringLiteral("conductor")}) {
const QDomNodeList nodes = xml_project.elementsByTagName(tag);
for (int i = 0; i < nodes.size(); ++i) {
const QUuid saved(nodes.at(i).toElement().attribute(QStringLiteral("uuid")));
if (!saved.isNull())
m_saved_item_uuids.insert(saved);
}
}
if(dlgWaiting)
dlgWaiting->setProgressBarRange(0, diagram_nodes.length()*3);
+4
View File
@@ -36,6 +36,7 @@
#endif
#include <QHash>
#include <QSet>
#include <QFuture>
class Diagram;
@@ -107,6 +108,7 @@ class QETProject : public QObject
ProjectPropertiesHandler& projectPropertiesHandler();
projectDataBase *dataBase();
QUuid uuid() const;
QUuid derivedItemUuid(const QString &kind, const QString &key);
ProjectState state() const;
QList<Diagram *> diagrams() const;
int folioIndex(const Diagram *) const;
@@ -365,6 +367,8 @@ class QETProject : public QObject
QFuture<bool> m_backup_future;
KAutoSaveFile m_backup_file;
QUuid m_uuid = QUuid::createUuid();
QHash<QString, int> m_derived_uuid_keys;
QSet<QUuid> m_saved_item_uuids; //symbol and wire uuids the file carries, see derivedItemUuid()
projectDataBase m_data_base;
QVector<TerminalStrip *> m_terminal_strip_vector;
+54 -8
View File
@@ -867,6 +867,18 @@ bool QetScriptApi::addConductor(int folioIndex,
return t1->isLinkedTo(t2);
}
namespace {
/// "{element uuid} terminal N", the form conductors() prints an end in and
/// the conductor calls take as element uuid + terminal index.
QString describeEnd(Terminal *t)
{
if (!t || !t->parentElement()) return QStringLiteral("?");
return QStringLiteral("%1 terminal %2")
.arg(t->parentElement()->uuid().toString())
.arg(t->parentElement()->terminals().indexOf(t));
}
} // namespace
/**
@brief QetScriptApi::conductors
One line per conductor on the folio: which terminals it joins and its
@@ -882,23 +894,57 @@ QStringList QetScriptApi::conductors(int folioIndex) const
const QList<Diagram *> diagrams = m_project->diagrams();
if (folioIndex < 0 || folioIndex >= diagrams.count()) return list;
auto describe = [](Terminal *t) -> QString {
if (!t || !t->parentElement()) return QStringLiteral("?");
return QStringLiteral("%1 terminal %2")
.arg(t->parentElement()->uuid().toString())
.arg(t->parentElement()->terminals().indexOf(t));
};
DiagramContent content(diagrams.at(folioIndex), false);
const QList<Conductor *> all = content.conductors(DiagramContent::AnyConductor);
for (Conductor *c : all)
{
list << QStringLiteral("%1 -- %2 : num='%3'")
.arg(describe(c->terminal1), describe(c->terminal2), c->properties().text);
.arg(describeEnd(c->terminal1), describeEnd(c->terminal2), c->properties().text);
}
return list;
}
/**
@brief QetScriptApi::conductorUuids
The uuid of every conductor on the folio, in the order conductors()
lists them.
*/
QStringList QetScriptApi::conductorUuids(int folioIndex) const
{
QStringList list;
if (!m_project) return list;
const QList<Diagram *> diagrams = m_project->diagrams();
if (folioIndex < 0 || folioIndex >= diagrams.count()) return list;
DiagramContent content(diagrams.at(folioIndex), false);
for (Conductor *c : content.conductors(DiagramContent::AnyConductor))
list << c->uuid().toString();
return list;
}
/**
@brief QetScriptApi::conductorEnds
The two ends of the conductor carrying @p uuid, each as
"{element uuid} terminal N" -- the element uuid and terminal index the
conductor calls take -- or an empty list if the folio has no such
conductor. A uuid names one conductor even where two meet at a terminal,
which an element uuid + terminal index cannot.
*/
QStringList QetScriptApi::conductorEnds(int folioIndex, const QString &uuid) const
{
if (!m_project) return {};
const QList<Diagram *> diagrams = m_project->diagrams();
if (folioIndex < 0 || folioIndex >= diagrams.count()) return {};
const QUuid wanted(uuid);
if (wanted.isNull()) return {};
DiagramContent content(diagrams.at(folioIndex), false);
for (Conductor *c : content.conductors(DiagramContent::AnyConductor))
if (c->uuid() == wanted)
return {describeEnd(c->terminal1), describeEnd(c->terminal2)};
return {};
}
QString QetScriptApi::conductorProperty(int folioIndex, const QString &elementUuid,
int terminalIndex, const QString &property) const
{
+2
View File
@@ -398,6 +398,8 @@ class QetScriptApi : public QObject
// -- conductor properties, applied to the whole potential --
Q_INVOKABLE QStringList conductors(int folioIndex) const;
Q_INVOKABLE QStringList conductorUuids(int folioIndex) const;
Q_INVOKABLE QStringList conductorEnds(int folioIndex, const QString &uuid) const;
Q_INVOKABLE QString conductorProperty(int folioIndex, const QString &elementUuid,
int terminalIndex, const QString &property) const;
Q_INVOKABLE bool setConductorProperty(int folioIndex, const QString &elementUuid,
+70
View File
@@ -328,3 +328,73 @@ target_include_directories(tst_conductorselfretrace PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_conductorselfretrace PRIVATE Qt::Test)
target_compile_definitions(tst_conductorselfretrace PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# qet.conductorUuids() / qet.conductorEnds(): a script lists a folio's
# conductors by uuid and finds each one's two ends. Runs a script through
# the real binary's --run on fixtures/qet_bug_repro_resaved.qet, so only
# where --run exists: a build without the Qt Qml module has no scripting
# (see QET_HAS_SCRIPTING in the top-level CMakeLists.txt).
if(QET_HAS_SCRIPTING)
add_executable(
tst_scriptconductoruuid
tst_scriptconductoruuid.cpp)
add_test(NAME tst_scriptconductoruuid COMMAND tst_scriptconductoruuid)
add_dependencies(tst_scriptconductoruuid qelectrotech)
target_link_libraries(tst_scriptconductoruuid PRIVATE Qt::Test)
target_compile_definitions(tst_scriptconductoruuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
endif()
# A symbol saved without a uuid gets the same one on every load, and
# inserting a folio does not change it. Runs the real binary's --resave on
# fixtures/qet_bug_repro_resaved.qet with the symbols' uuids stripped.
add_executable(
tst_derivedsymboluuid
tst_derivedsymboluuid.cpp)
add_test(NAME tst_derivedsymboluuid COMMAND tst_derivedsymboluuid)
add_dependencies(tst_derivedsymboluuid qelectrotech)
target_link_libraries(tst_derivedsymboluuid PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_derivedsymboluuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# A wire saved without a uuid gets one derived from its two ends: the same
# on every load, saved, and unchanged by inserting a folio, reordering the
# wires or drawing a wire the other way. Runs the real binary's --resave on
# a fixture and on examples/tremie_vibrante.qet (ends by terminal number).
add_executable(
tst_derivedwireuuid
tst_derivedwireuuid.cpp)
add_test(NAME tst_derivedwireuuid COMMAND tst_derivedwireuuid)
add_dependencies(tst_derivedwireuuid qelectrotech)
target_link_libraries(tst_derivedwireuuid PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_derivedwireuuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# Saving a project that was just saved changes nothing: runs the real
# binary's --resave twice on every project in examples/, and on one whose
# title block holds a single-space value (#973).
add_executable(
tst_resaveunchanged
tst_resaveunchanged.cpp)
add_test(NAME tst_resaveunchanged COMMAND tst_resaveunchanged)
add_dependencies(tst_resaveunchanged qelectrotech)
target_link_libraries(tst_resaveunchanged PRIVATE Qt::Test)
target_compile_definitions(tst_resaveunchanged PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# DiagramContext::fromXml() -- the two readers (QDom for projects, pugixml
# for element definitions in the collection) give the same values: stray
# spaces trimmed, accents kept.
add_executable(
tst_diagramcontext
tst_diagramcontext.cpp
${QET_DIR}/sources/diagramcontext.cpp
${QET_DIR}/sources/qet.cpp
${QET_DIR}/sources/qeticons.cpp
${QET_DIR}/sources/shortcutmanager.cpp)
add_test(NAME tst_diagramcontext COMMAND tst_diagramcontext)
target_include_directories(tst_diagramcontext PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_diagramcontext PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml)
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QHash>
#include <QProcess>
#include <QProcessEnvironment>
#include <QSet>
#include <QTemporaryDir>
#include <QUuid>
// A symbol saved without a uuid must get the same one on every load of the
// same file, and inserting a folio in front of it must not change it. Until
// this was fixed it got a random uuid each time (Element::fromXml), which
// the next save wrote out.
//
// Runs the real binary (--resave) on the fixture with its symbols' uuids
// stripped, and reads the uuids back from the saved file.
namespace {
QDomDocument load(const QString &path)
{
QDomDocument doc;
QFile file(path);
if (file.open(QIODevice::ReadOnly))
doc.setContent(&file);
return doc;
}
QList<QDomElement> symbols(const QDomElement &diagram)
{
QList<QDomElement> out;
const QDomNodeList nodes = diagram.firstChildElement(QStringLiteral("elements"))
.elementsByTagName(QStringLiteral("element"));
for (int i = 0; i < nodes.size(); ++i) {
const QDomElement e = nodes.at(i).toElement();
if (e.parentNode().parentNode() == diagram)
out << e;
}
return out;
}
QList<QDomElement> diagrams(const QDomDocument &doc)
{
QList<QDomElement> out;
for (QDomElement d = doc.documentElement().firstChildElement(QStringLiteral("diagram"));
!d.isNull(); d = d.nextSiblingElement(QStringLiteral("diagram")))
out << d;
return out;
}
// Symbols without uuids, and no wires (they refer to symbols by uuid).
void stripUuids(QDomDocument &doc)
{
for (QDomElement d : diagrams(doc)) {
for (QDomElement e : symbols(d))
e.removeAttribute(QStringLiteral("uuid"));
d.removeChild(d.firstChildElement(QStringLiteral("conductors")));
}
}
// type|x|y|orientation -> uuid, for every symbol in the file
QMultiHash<QString, QString> symbolUuids(const QDomDocument &doc)
{
QMultiHash<QString, QString> out;
for (const QDomElement &d : diagrams(doc))
for (const QDomElement &e : symbols(d))
out.insert(QStringList{e.attribute(QStringLiteral("type")),
e.attribute(QStringLiteral("x")),
e.attribute(QStringLiteral("y")),
e.attribute(QStringLiteral("orientation"))}
.join(QLatin1Char('|')),
e.attribute(QStringLiteral("uuid")));
return out;
}
} // namespace
class tst_derivedsymboluuid : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
// Save @p doc, run --resave on it in a sandbox of its own (so a running
// QElectroTech cannot answer instead), and return what was saved.
QDomDocument resave(const QDomDocument &doc)
{
const QString in = m_dir.filePath(QStringLiteral("in%1.qet").arg(m_run));
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run));
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QFile f(in);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(doc.toByteArray());
f.close();
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--resave"), in, out});
if (!proc.waitForFinished(60000) || proc.exitCode() != 0) return {};
return load(out);
}
QDomDocument fixture()
{
QDomDocument doc = load(QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet"));
stripUuids(doc);
return doc;
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
QVERIFY(!fixture().isNull());
}
void sameUuidsOnEveryLoad()
{
const QMultiHash<QString, QString> a = symbolUuids(resave(fixture()));
const QMultiHash<QString, QString> b = symbolUuids(resave(fixture()));
QCOMPARE(a.size(), 9); // the fixture's placed symbols
QCOMPARE(a, b);
for (const QString &u : a)
QVERIFY2(!QUuid(u).isNull(), qPrintable(u));
QCOMPARE(QSet<QString>(a.begin(), a.end()).size(), a.size());
}
void insertingAFolioChangesNothing()
{
QDomDocument moved = fixture();
QDomElement first = diagrams(moved).first();
QDomElement blank = first.cloneNode(false).toElement();
blank.setAttribute(QStringLiteral("title"), QStringLiteral("new"));
blank.appendChild(moved.createElement(QStringLiteral("elements")));
moved.documentElement().insertBefore(blank, first);
const QMultiHash<QString, QString> after = symbolUuids(resave(moved));
QCOMPARE(after.size(), 9);
QCOMPARE(after, symbolUuids(resave(fixture())));
}
void savedUuidsAreKept()
{
QDomDocument doc = load(QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet"));
QCOMPARE(symbolUuids(resave(doc)), symbolUuids(doc));
}
// A symbol keeps its derived uuid once saved, even when moved. A symbol
// saved without a uuid that later turns up on the spot it left (a hand
// edit, an older version, another tool) would derive the same uuid: it
// must get another one instead.
void newcomerOnAMovedSymbolsSpotGetsAnotherUuid()
{
QDomDocument doc = resave(fixture());
QDomElement moved = symbols(diagrams(doc).first()).first();
QVERIFY(!QUuid(moved.attribute(QStringLiteral("uuid"))).isNull());
QDomElement newcomer = moved.cloneNode(true).toElement();
newcomer.removeAttribute(QStringLiteral("uuid"));
const QDomNodeList terminals = newcomer.elementsByTagName(QStringLiteral("terminal"));
for (int i = 0; i < terminals.size(); ++i)
terminals.at(i).toElement().setAttribute(QStringLiteral("id"), 90000 + i);
moved.setAttribute(QStringLiteral("x"), moved.attribute(QStringLiteral("x")).toInt() + 500);
moved.parentNode().appendChild(newcomer);
const QMultiHash<QString, QString> after = symbolUuids(resave(doc));
QCOMPARE(after.size(), 10);
QCOMPARE(QSet<QString>(after.begin(), after.end()).size(), 10);
}
void stackedIdenticalSymbolsDiffer()
{
QDomDocument doc = fixture();
QDomElement one = symbols(diagrams(doc).first()).first();
QDomElement copy = one.cloneNode(true).toElement();
//A copy's terminals carry their own file ids; a symbol whose
//terminal ids are already taken is not loaded at all.
const QDomNodeList terminals = copy.elementsByTagName(QStringLiteral("terminal"));
for (int i = 0; i < terminals.size(); ++i)
terminals.at(i).toElement().setAttribute(QStringLiteral("id"), 90000 + i);
one.parentNode().appendChild(copy);
const QStringList both = symbolUuids(resave(doc)).values(
QStringList{one.attribute(QStringLiteral("type")),
one.attribute(QStringLiteral("x")),
one.attribute(QStringLiteral("y")),
one.attribute(QStringLiteral("orientation"))}
.join(QLatin1Char('|')));
QCOMPARE(both.size(), 2);
QVERIFY(both.at(0) != both.at(1));
}
};
QTEST_APPLESS_MAIN(tst_derivedsymboluuid)
#include "tst_derivedsymboluuid.moc"
+307
View File
@@ -0,0 +1,307 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QHash>
#include <QMap>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QUuid>
// A wire saved without a uuid must get one that is the same on every load,
// is written on save, and survives the edits people make before that save:
// inserting a folio, saving the wires in another order, drawing the wire
// the other way round. Until this was fixed it got a random uuid on every
// load, never saved (#754).
//
// Runs the real binary (--resave) on two fixtures -- one whose wires name
// their ends by symbol and terminal uuid, one older file naming them by
// terminal number -- and reads the wires' uuids back from the saved file.
namespace {
QDomDocument load(const QString &path)
{
QDomDocument doc;
QFile file(path);
if (file.open(QIODevice::ReadOnly))
doc.setContent(&file);
return doc;
}
QList<QDomElement> diagrams(const QDomDocument &doc)
{
QList<QDomElement> out;
for (QDomElement d = doc.documentElement().firstChildElement(QStringLiteral("diagram"));
!d.isNull(); d = d.nextSiblingElement(QStringLiteral("diagram")))
out << d;
return out;
}
QList<QDomElement> wires(const QDomElement &diagram)
{
QList<QDomElement> out;
const QDomElement block = diagram.firstChildElement(QStringLiteral("conductors"));
for (QDomElement c = block.firstChildElement(QStringLiteral("conductor"));
!c.isNull(); c = c.nextSiblingElement(QStringLiteral("conductor")))
out << c;
return out;
}
// Every wire's uuid in the file, sorted: equal lists mean every wire kept
// its uuid.
QStringList wireUuids(const QDomDocument &doc)
{
QStringList out;
for (const QDomElement &d : diagrams(doc))
for (const QDomElement &c : wires(d))
out << c.attribute(QStringLiteral("uuid"));
out.sort();
return out;
}
// Which wire carries which uuid: each wire keyed by its two ends as the
// saved file names them, sorted. An older file names an end by terminal
// number, resolved here to the symbol and the terminal's place on it.
QMap<QString, QString> wireMap(const QDomDocument &doc)
{
QMap<QString, QString> out;
for (const QDomElement &d : diagrams(doc)) {
QHash<QString, QString> by_number;
const QDomNodeList symbols = d.firstChildElement(QStringLiteral("elements"))
.elementsByTagName(QStringLiteral("element"));
for (int i = 0; i < symbols.size(); ++i) {
const QDomElement e = symbols.at(i).toElement();
const QDomNodeList ts = e.elementsByTagName(QStringLiteral("terminal"));
for (int j = 0; j < ts.size(); ++j) {
const QDomElement t = ts.at(j).toElement();
by_number.insert(t.attribute(QStringLiteral("id")),
e.attribute(QStringLiteral("uuid")) + QLatin1Char('/')
+ t.attribute(QStringLiteral("x")) + QLatin1Char(',')
+ t.attribute(QStringLiteral("y")));
}
}
for (const QDomElement &c : wires(d)) {
QStringList ends;
for (const QString n : {QStringLiteral("1"), QStringLiteral("2")}) {
const QString element = c.attribute(QStringLiteral("element") + n);
const QString terminal = c.attribute(QStringLiteral("terminal") + n);
ends << (element.isEmpty() ? by_number.value(terminal, QStringLiteral("?"))
: element + QLatin1Char('/') + terminal);
}
ends.sort();
out.insert(ends.join(QLatin1Char('|')), c.attribute(QStringLiteral("uuid")));
}
}
return out;
}
} // namespace
class tst_derivedwireuuid : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
// Save @p doc, run --resave on it in a sandbox of its own (so a running
// QElectroTech cannot answer instead), and return what was saved.
QDomDocument resave(const QDomDocument &doc)
{
const QString in = m_dir.filePath(QStringLiteral("in%1.qet").arg(m_run));
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run));
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QFile f(in);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(doc.toByteArray());
f.close();
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--resave"), in, out});
if (!proc.waitForFinished(120000) || proc.exitCode() != 0) return {};
return load(out);
}
// The fixture as an older QElectroTech saved it: wires without uuids.
QDomDocument fixture()
{
QFETCH(QString, path);
QDomDocument doc = load(path);
for (const QDomElement &d : diagrams(doc))
for (QDomElement c : wires(d))
c.removeAttribute(QStringLiteral("uuid"));
return doc;
}
// The saved uuids of the unedited fixture, checked for sanity.
QStringList reference()
{
QFETCH(int, count);
const QStringList ids = wireUuids(resave(fixture()));
if (ids.size() != count) return {};
for (const QString &u : ids)
if (QUuid(u).isNull()) return {};
return ids;
}
void fixtures()
{
QTest::addColumn<QString>("path");
QTest::addColumn<int>("count");
QTest::newRow("ends by uuid")
<< QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet") << 7;
QTest::newRow("ends by terminal number (older file)")
<< QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet") << 77;
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void savedAndUnique_data() { fixtures(); }
void savedAndUnique()
{
QFETCH(int, count);
const QStringList ids = reference();
QCOMPARE(ids.size(), count); // every wire has one
QCOMPARE(QSet<QString>(ids.begin(), ids.end()).size(), count); // all different
QCOMPARE(wireMap(resave(fixture())).size(), count); // the test's wire key is unique too
}
void sameOnEveryLoad_data() { fixtures(); }
void sameOnEveryLoad()
{
const QStringList a = reference();
QVERIFY(!a.isEmpty());
QCOMPARE(wireUuids(resave(fixture())), a);
}
void savedUuidIsReadBack_data() { fixtures(); }
void savedUuidIsReadBack()
{
const QDomDocument once = resave(fixture());
QVERIFY(!wireUuids(once).isEmpty());
QCOMPARE(wireUuids(resave(once)), wireUuids(once));
}
void insertingAFolioChangesNothing_data() { fixtures(); }
void insertingAFolioChangesNothing()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
const QDomElement first = diagrams(doc).first();
QDomElement blank = first.cloneNode(false).toElement();
blank.setAttribute(QStringLiteral("title"), QStringLiteral("new"));
blank.removeAttribute(QStringLiteral("uuid"));
blank.appendChild(doc.createElement(QStringLiteral("elements")));
doc.documentElement().insertBefore(blank, first);
QCOMPARE(wireMap(resave(doc)), before);
}
void wireOrderDoesNotMatter_data() { fixtures(); }
void wireOrderDoesNotMatter()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
for (const QDomElement &d : diagrams(doc)) {
QDomElement block = d.firstChildElement(QStringLiteral("conductors"));
const QList<QDomElement> ws = wires(d);
for (const QDomElement &w : ws)
block.removeChild(w);
for (auto it = ws.crbegin(); it != ws.crend(); ++it) // reversed
block.appendChild(*it);
}
QCOMPARE(wireMap(resave(doc)), before);
}
void directionDoesNotMatter_data() { fixtures(); }
void directionDoesNotMatter()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
static const QRegularExpression pair(
QStringLiteral("^(element|terminal|terminalname)([12])(.*)$"));
for (const QDomElement &d : diagrams(doc)) {
for (QDomElement w : wires(d)) {
const QDomNamedNodeMap attrs = w.attributes();
QHash<QString, QString> swapped;
for (int i = 0; i < attrs.size(); ++i) {
const QString name = attrs.item(i).nodeName();
const auto m = pair.match(name);
if (m.hasMatch())
swapped.insert(m.captured(1) + (m.captured(2) == QLatin1String("1")
? QStringLiteral("2") : QStringLiteral("1"))
+ m.captured(3),
attrs.item(i).nodeValue());
}
for (auto it = swapped.cbegin(); it != swapped.cend(); ++it)
w.setAttribute(it.key(), it.value());
}
}
QCOMPARE(wireMap(resave(doc)), before);
}
// A wire keeps its uuid once saved, even when re-connected. A wire saved
// without a uuid that later turns up on the ends it left (a hand edit, an
// older version, another tool) would derive the same uuid: it must get
// another one instead.
void newcomerOnAReconnectedWiresEndsGetsAnotherUuid_data() { fixtures(); }
void newcomerOnAReconnectedWiresEndsGetsAnotherUuid()
{
QFETCH(int, count);
QDomDocument doc = resave(fixture());
QVERIFY(!wireUuids(doc).isEmpty());
const QList<QDomElement> ws = wires(diagrams(doc).first());
QVERIFY(ws.size() >= 2);
QDomElement x = ws.at(0);
const QDomElement y = ws.at(1);
QDomElement newcomer = x.cloneNode(true).toElement(); // x's old ends
newcomer.removeAttribute(QStringLiteral("uuid"));
// re-connect x's second end to y's second end, keeping x's uuid
static const QRegularExpression end2(QStringLiteral("^(element|terminal|terminalname)2"));
const QDomNamedNodeMap attrs = y.attributes();
for (int i = 0; i < attrs.size(); ++i)
if (end2.match(attrs.item(i).nodeName()).hasMatch())
x.setAttribute(attrs.item(i).nodeName(), attrs.item(i).nodeValue());
x.parentNode().appendChild(newcomer);
const QStringList after = wireUuids(resave(doc));
QCOMPARE(after.size(), count + 1);
QCOMPARE(QSet<QString>(after.begin(), after.end()).size(), count + 1);
}
// QElectroTech refuses a second wire between two terminals already joined
// (Terminal::canBeLinkedTo()), so two wires on one folio never share both
// ends. A file that has one anyway must still load to the same uuids.
void doubledWireIsDroppedAndOthersKeepTheirs_data() { fixtures(); }
void doubledWireIsDroppedAndOthersKeepTheirs()
{
QVERIFY(!reference().isEmpty());
const QMap<QString, QString> before = wireMap(resave(fixture()));
QDomDocument doc = fixture();
QDomElement w = wires(diagrams(doc).first()).first();
w.parentNode().appendChild(w.cloneNode(true));
QCOMPARE(wireMap(resave(doc)), before);
}
};
QTEST_APPLESS_MAIN(tst_derivedwireuuid)
#include "tst_derivedwireuuid.moc"
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include "diagramcontext.h"
#include "qetapp.h"
QString QETApp::m_interface_language;
/**
DiagramContext::fromXml() has two readers: QDom, for projects, and
pugixml, for element definitions in the collection. Both must give the
values the next save writes -- stray spaces around real content trimmed,
accented characters kept.
*/
class tst_diagramcontext : public QObject
{
Q_OBJECT
static QByteArray xml(const QString &value)
{
return QStringLiteral(
"<elementInformations>"
"<elementInformation name=\"v\" show=\"1\">%1</elementInformation>"
"</elementInformations>")
.arg(value)
.toUtf8();
}
static QString fromDom(const QByteArray &data)
{
QDomDocument doc;
if (!doc.setContent(data)) return QStringLiteral("<parse error>");
DiagramContext dc;
dc.fromXml(doc.documentElement(), QStringLiteral("elementInformation"));
return dc.value(QStringLiteral("v")).toString();
}
static QString fromPugi(const QByteArray &data)
{
pugi::xml_document doc;
if (!doc.load_buffer(data.constData(), size_t(data.size())))
return QStringLiteral("<parse error>");
DiagramContext dc;
dc.fromXml(doc.document_element(), QStringLiteral("elementInformation"));
return dc.value(QStringLiteral("v")).toString();
}
private slots:
void bothReadersAgree_data()
{
QTest::addColumn<QString>("value");
QTest::addColumn<QString>("expected");
QTest::newRow("plain") << "PRISE" << "PRISE";
QTest::newRow("stray spaces") << " PRISE " << "PRISE";
QTest::newRow("accents") << "Armoire façade été" << "Armoire façade été";
QTest::newRow("accents and stray spaces") << " Moteur à cage " << "Moteur à cage";
QTest::newRow("non-Latin") << "Двигатель 電機" << "Двигатель 電機";
}
void bothReadersAgree()
{
QFETCH(QString, value);
QFETCH(QString, expected);
QCOMPARE(fromDom(xml(value)), expected);
QCOMPARE(fromPugi(xml(value)), expected);
}
};
QTEST_APPLESS_MAIN(tst_diagramcontext)
#include "tst_diagramcontext.moc"
+70
View File
@@ -121,6 +121,48 @@ private slots:
b = nullptr;
}
// A second click on a member of a group selected whole picks that member
// out; a click on a member of a group not selected whole does not.
void aMemberOfAWholeGroupCanBePicked()
{
select({a, b});
QCOMPARE(ItemGroups::memberToPick(a), a);
QCOMPARE(ItemGroups::memberToPick(b), b);
}
void aMemberOfAPartlySelectedGroupIsNotPicked()
{
select({a}); // after one member was picked
QCOMPARE(ItemGroups::memberToPick(a), nullptr);
select({});
QCOMPARE(ItemGroups::memberToPick(a), nullptr);
}
void anUngroupedItemIsNotPicked()
{
select({c});
QCOMPARE(ItemGroups::memberToPick(c), nullptr);
QCOMPARE(ItemGroups::memberToPick(nullptr), nullptr);
}
void aGroupOfOneIsNotPicked()
{
ItemGroups::setGroup(e, QUuid()); // g2 is now d alone
select({d});
QCOMPARE(ItemGroups::memberToPick(d), nullptr);
}
// A click lands on a symbol's own text, not on the symbol: the member is
// the nearest grouped ancestor.
void aClickOnAMembersChildPicksTheMember()
{
auto child = new QGraphicsRectItem(0, 0, 2, 2, a);
QCOMPARE(ItemGroups::groupedItem(child), a);
select({a, b});
QCOMPARE(ItemGroups::memberToPick(child), a);
QCOMPARE(ItemGroups::groupedItem(c), nullptr);
}
void xmlRoundTrip()
{
QDomDocument doc;
@@ -136,6 +178,34 @@ private slots:
ItemGroups::setGroup(a, QUuid());
QVERIFY(ItemGroups::groupOf(a).isNull());
}
// Rotate turns a selection that is exactly one whole group as one piece.
void aWholeGroupAloneIsASoleWholeGroup()
{
select({a, b});
QCOMPARE(ItemGroups::soleWholeGroup(selection()), g1);
}
void aPickedMemberIsNotAWholeGroup()
{
select({a});
QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull());
}
void aGroupWithOtherItemsIsNotASoleGroup()
{
select({a, b, c});
QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull());
select({a, b, d, e});
QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull());
}
void ungroupedItemsAreNotAGroup()
{
select({c});
QVERIFY(ItemGroups::soleWholeGroup(selection()).isNull());
QVERIFY(ItemGroups::soleWholeGroup({}).isNull());
}
};
QTEST_MAIN(tst_itemgroups)
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QTemporaryDir>
// Saving a project that was just saved must change nothing. Two things
// made the second save differ from the first, both cleanup done on save
// but not on load:
// - symbol information whose values were all empty was written as an
// empty <elementInformations/> block, which the next load read as no
// information and the next save dropped (Projet_vierge.qet);
// - information values were trimmed on save but not on load, so a label
// with stray spaces kept them in its displayed copy until the project
// was opened again (m_000.qet).
// Runs the real binary's --resave twice on every example, and on a
// project whose title block holds a value that is a single space (#973).
class tst_resaveunchanged : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
// --resave @p in to a new file, in a sandbox of its own (so a running
// QElectroTech cannot answer instead); returns the new file's path.
QString resave(const QString &in)
{
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run));
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--resave"), in, out});
if (!proc.waitForFinished(120000) || proc.exitCode() != 0) return {};
return out;
}
static QByteArray read(const QString &path)
{
QFile f(path);
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
// Projet_vierge.qet has empty information values, m_000.qet values
// with stray spaces; every other example is here so a new cause shows.
void secondSaveChangesNothing_data()
{
QTest::addColumn<QString>("project");
const QDir examples(QStringLiteral(QET_EXAMPLES_DIR));
const QStringList projects =
examples.entryList({QStringLiteral("*.qet")}, QDir::Files, QDir::Name);
QVERIFY(!projects.isEmpty());
for (const QString &project : projects)
QTest::newRow(project.toUtf8().constData()) << examples.filePath(project);
}
void secondSaveChangesNothing()
{
QFETCH(QString, project);
const QString first = resave(project);
QVERIFY2(!first.isEmpty(), "first --resave failed");
const QString second = resave(first);
QVERIFY2(!second.isEmpty(), "second --resave failed");
const QByteArray a = read(first), b = read(second);
QVERIFY(!a.isEmpty());
QVERIFY2(a == b, "the second save changed the file");
}
// A title-block value that is a single space is kept through two saves
// (#973), and a value with accents comes back as it went in.
void singleSpaceValueKept()
{
QByteArray xml = read(QStringLiteral(QET_EXAMPLES_DIR "/Projet_vierge.qet"));
QVERIFY(xml.contains("<properties>"));
xml.replace("<properties>",
"<properties>"
"<property show=\"1\" name=\"space\"> </property>"
"<property show=\"1\" name=\"accents\">Armoire façade été</property>");
const QString in = m_dir.filePath(QStringLiteral("space.qet"));
QFile f(in);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(xml);
f.close();
const QString first = resave(in);
QVERIFY2(!first.isEmpty(), "first --resave failed");
const QString second = resave(first);
QVERIFY2(!second.isEmpty(), "second --resave failed");
const QByteArray a = read(first), b = read(second);
QVERIFY2(a == b, "the second save changed the file");
const QString saved = QString::fromUtf8(b);
QVERIFY2(saved.contains(QRegularExpression(
QStringLiteral("<property [^>]*name=\"space\"[^>]*> </property>"))),
"the single-space value was lost");
QVERIFY2(saved.contains(QRegularExpression(
QStringLiteral("<property [^>]*name=\"accents\"[^>]*>Armoire façade été</property>"))),
"the accented value changed");
}
};
QTEST_APPLESS_MAIN(tst_resaveunchanged)
#include "tst_resaveunchanged.moc"
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
#include <QUuid>
// qet.conductorUuids(folio) and qet.conductorEnds(folio, uuid): a script can
// list a folio's conductors by uuid and find where each one runs, in the
// "{element} terminal N" form conductors() prints and the conductor calls
// take. Runs a script through the real binary's --run.
class tst_scriptconductoruuid : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// Run @p script on the fixture in a sandbox of its own and return the
// JSON object it logged.
QJsonObject run(const QString &script)
{
const QString path = m_dir.filePath(QStringLiteral("probe.js"));
const QString home = m_dir.filePath(QStringLiteral("home"));
QDir().mkpath(home);
QFile f(path);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(script.toUtf8());
f.close();
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--run"), path,
QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet")});
if (!proc.waitForFinished(60000)) return {};
const QString out = QString::fromUtf8(proc.readAllStandardOutput()
+ proc.readAllStandardError());
const QString mark = QStringLiteral("PROBE ");
for (const QString &line : out.split(QLatin1Char('\n'))) {
const int i = line.indexOf(mark);
if (i >= 0)
return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object();
}
return {};
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void uuidsAndEndsMatchConductors()
{
const QJsonObject r = run(QStringLiteral(
"var uuids = qet.conductorUuids(0);\n"
"var ends = uuids.map(function (u) { return qet.conductorEnds(0, u); });\n"
"qet.log('PROBE ' + JSON.stringify({lines: qet.conductors(0), uuids: uuids, ends: ends,\n"
" unknown: qet.conductorEnds(0, '{00000000-0000-0000-0000-000000000001}'),\n"
" junk: qet.conductorEnds(0, 'not a uuid'),\n"
" badFolio: qet.conductorUuids(99)}));\n"));
QVERIFY2(!r.isEmpty(), "the script logged nothing");
const QJsonArray lines = r.value(QStringLiteral("lines")).toArray();
const QJsonArray uuids = r.value(QStringLiteral("uuids")).toArray();
const QJsonArray ends = r.value(QStringLiteral("ends")).toArray();
QCOMPARE(lines.size(), 7); // the fixture's conductors
QCOMPARE(uuids.size(), lines.size());
QSet<QString> distinct;
for (int i = 0; i < uuids.size(); ++i) {
const QString u = uuids.at(i).toString();
QVERIFY2(!QUuid(u).isNull(), qPrintable(u));
distinct.insert(u);
// same order as conductors(), and the same two ends it prints
const QJsonArray e = ends.at(i).toArray();
QCOMPARE(e.size(), 2);
const QString expected = e.at(0).toString() + QStringLiteral(" -- ")
+ e.at(1).toString() + QStringLiteral(" : ");
QVERIFY2(lines.at(i).toString().startsWith(expected),
qPrintable(lines.at(i).toString() + QStringLiteral(" | ") + expected));
}
QCOMPARE(distinct.size(), uuids.size());
QVERIFY(r.value(QStringLiteral("unknown")).toArray().isEmpty());
QVERIFY(r.value(QStringLiteral("junk")).toArray().isEmpty());
QVERIFY(r.value(QStringLiteral("badFolio")).toArray().isEmpty());
}
};
QTEST_APPLESS_MAIN(tst_scriptconductoruuid)
#include "tst_scriptconductoruuid.moc"