Merge pull request #1534 from ispyisail/feat/multi-symbol-info

Add editing the information of several symbols at once (#1439)
This commit is contained in:
ispyisail
2026-10-18 22:31:36 +13:00
committed by GitHub
12 changed files with 1506 additions and 48 deletions
+7
View File
@@ -267,6 +267,13 @@ endfunction()
# alone and with a group.
qet_add_app_test(tst_rotateselectionimage tst_rotateselectionimage.cpp)
# Editing the information of several elements at once (#1439): kept, set
# and cleared fields, restore and reset, one undo step, the selection
# panel's editor and the Edit action.
qet_add_app_test(tst_multielementinfo tst_multielementinfo.cpp)
target_compile_definitions(tst_multielementinfo PRIVATE
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# Accepting the settings dialog writes qet-assistant.json with the
# collection paths just chosen, not the ones they replace.
qet_add_app_test(tst_assistantinfopaths tst_assistantinfopaths.cpp)
+412
View File
@@ -0,0 +1,412 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "qetapptest.h"
#include "diagramcontent.h"
#include "factory/propertieseditorfactory.h"
#include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/dynamicelementtextitem.h"
#include "qetgraphicsitem/element.h"
#include "qetinformation.h"
#include "projectview.h"
#include "qetproject.h"
#include "ui/elementinfopartwidget.h"
#include "ui/elementinfowidget.h"
#include "ui/elementpropertieswidget.h"
#include "ui/multielementinfowidget.h"
#include "undocommand/changeelementinformationcommand.h"
#include <QCheckBox>
#include <QTabWidget>
#include <QUndoStack>
// Editing the information of several elements at once (#1439): a field left
// alone keeps each element's own value, a typed text goes to every element,
// the erase box clears the field on every element, the restore button and
// reset() bring back what the elements had, and one apply is one undo step.
class tst_multielementinfo : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
Diagram *m_diagram = nullptr;
QList<Element *> m_elements;
Conductor *m_conductor = nullptr;
QString info(Element *e, const QString &key) const
{
return e->elementInformations().value(key).toString();
}
QUndoStack &stack() { return m_diagram->undoStack(); }
// The three test elements get known information, as one undo step
// the test then leaves on the stack
void setKnownInfo()
{
QMap<QPointer<Element>, QPair<DiagramContext, DiagramContext>> map;
const QStringList suppliers {"S1", "S2", ""};
for (int i = 0 ; i < 3 ; ++i)
{
Element *e = m_elements.at(i);
DiagramContext info;
info.addValue(QETInformation::ELMT_MANUFACTURER, "Maker");
if (!suppliers.at(i).isEmpty())
info.addValue(QETInformation::ELMT_SUPPLIER, suppliers.at(i));
info.addValue(QETInformation::ELMT_DESCRIPTION, QString("desc %1").arg(i));
info.addValue("my_custom_key", QString("custom %1").arg(i));
if (i == 0)
info.addValue("exclude_from_bom", "true");
map.insert(e, qMakePair(e->elementInformations(), info));
}
stack().push(new ChangeElementInformationCommand(map));
}
private slots:
void initTestCase()
{
QETDiagramEditor *editor = QetAppTest::editor();
QVERIFY(editor);
// The first example folio with three plain elements and a wire
const QStringList files = QDir(QET_EXAMPLES_DIR).entryList({"*.qet"}, QDir::Files, QDir::Name);
for (const QString &file : files)
{
// A copy: opening a project leaves a lock file beside it
const QString copy = m_dir.filePath(file);
QVERIFY(QFile::copy(QDir(QET_EXAMPLES_DIR).filePath(file), copy));
QVERIFY(editor->openAndAddProject(copy, false));
QETProject *project = editor->currentProjectView()->project();
for (Diagram *d : project->diagrams())
{
QList<Element *> simple;
Conductor *conductor = nullptr;
for (QGraphicsItem *qgi : d->items())
{
if (qgi->type() == Element::Type
&& static_cast<Element *>(qgi)->linkType() == Element::Simple)
simple << static_cast<Element *>(qgi);
else if (!conductor && qgi->type() == Conductor::Type)
conductor = static_cast<Conductor *>(qgi);
}
if (simple.size() >= 3 && conductor)
{
m_diagram = d;
m_elements = simple.mid(0, 3);
m_conductor = conductor;
break;
}
}
if (m_diagram)
break;
}
QVERIFY2(m_diagram, "no example folio with three plain elements and a wire");
setKnownInfo();
}
void init()
{
stack().setClean();
}
void cleanup()
{
// Back to the known information
while (stack().index() > stack().cleanIndex())
stack().undo();
}
void showsSharedAndMixedValues()
{
MultiElementInfoWidget w(m_elements);
QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_MANUFACTURER), false);
QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_SUPPLIER), true);
QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_DESCRIPTION), true);
// No element has a value: same (empty) value, not mixed
QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_QUANTITY), false);
QVERIFY(!w.associatedUndo());
}
void setGivesTextToEveryElementAsOneUndo()
{
MultiElementInfoWidget w(m_elements);
w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME");
QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Set);
const int before = stack().index();
w.apply();
QCOMPARE(stack().index(), before + 1);
for (Element *e : m_elements)
{
QCOMPARE(info(e, QETInformation::ELMT_SUPPLIER), QString("ACME"));
// Untouched fields keep each element's own value
QCOMPARE(info(e, QETInformation::ELMT_MANUFACTURER), QString("Maker"));
QCOMPARE(info(e, "my_custom_key"),
QString("custom %1").arg(m_elements.indexOf(e)));
}
QCOMPARE(info(m_elements[0], QETInformation::ELMT_DESCRIPTION), QString("desc 0"));
stack().undo();
QCOMPARE(info(m_elements[0], QETInformation::ELMT_SUPPLIER), QString("S1"));
QCOMPARE(info(m_elements[1], QETInformation::ELMT_SUPPLIER), QString("S2"));
QVERIFY(!m_elements[2]->elementInformations().contains(QETInformation::ELMT_SUPPLIER));
}
void eraseClearsEveryElement()
{
MultiElementInfoWidget w(m_elements);
w.setFieldCleared(QETInformation::ELMT_DESCRIPTION, true);
QCOMPARE(w.fieldState(QETInformation::ELMT_DESCRIPTION), MultiElementInfoWidget::Clear);
w.apply();
for (Element *e : m_elements) {
QVERIFY(!e->elementInformations().contains(QETInformation::ELMT_DESCRIPTION));
QCOMPARE(info(e, QETInformation::ELMT_MANUFACTURER), QString("Maker"));
}
}
// Emptying a field every element shares clears it, as in the
// one-element editor
void emptyingASharedValueClearsIt()
{
MultiElementInfoWidget w(m_elements);
w.setFieldText(QETInformation::ELMT_MANUFACTURER, QString());
QCOMPARE(w.fieldState(QETInformation::ELMT_MANUFACTURER), MultiElementInfoWidget::Set);
w.apply();
for (Element *e : m_elements)
QVERIFY(!e->elementInformations().contains(QETInformation::ELMT_MANUFACTURER));
}
// A mixed field emptied again, or a shared one typed back to its
// value, changes nothing
void typingBackToTheValueKeeps()
{
MultiElementInfoWidget w(m_elements);
w.setFieldText(QETInformation::ELMT_SUPPLIER, "x");
w.setFieldText(QETInformation::ELMT_SUPPLIER, QString());
QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Keep);
w.setFieldText(QETInformation::ELMT_MANUFACTURER, "Make");
w.setFieldText(QETInformation::ELMT_MANUFACTURER, "Maker");
QCOMPARE(w.fieldState(QETInformation::ELMT_MANUFACTURER), MultiElementInfoWidget::Keep);
QVERIFY(!w.associatedUndo());
}
void restoreAndResetBringBackTheValues()
{
MultiElementInfoWidget w(m_elements);
w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME");
w.setFieldCleared(QETInformation::ELMT_DESCRIPTION, true);
w.restoreField(QETInformation::ELMT_SUPPLIER);
QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Keep);
QCOMPARE(w.fieldState(QETInformation::ELMT_DESCRIPTION), MultiElementInfoWidget::Clear);
w.reset();
QCOMPARE(w.fieldState(QETInformation::ELMT_DESCRIPTION), MultiElementInfoWidget::Keep);
QVERIFY(!w.associatedUndo());
}
// The selection panel applies each keystroke; they make one undo
// step, and restoring the field in the panel undoes it
void liveEditMergesAndRestores()
{
MultiElementInfoWidget w(m_elements);
w.setLiveEdit(true);
const int before = stack().index();
w.setFieldText(QETInformation::ELMT_SUPPLIER, "A");
w.setFieldText(QETInformation::ELMT_SUPPLIER, "AC");
w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME");
QCOMPARE(stack().index(), before + 1);
for (Element *e : m_elements)
QCOMPARE(info(e, QETInformation::ELMT_SUPPLIER), QString("ACME"));
w.restoreField(QETInformation::ELMT_SUPPLIER);
QCOMPARE(info(m_elements[0], QETInformation::ELMT_SUPPLIER), QString("S1"));
QCOMPARE(info(m_elements[1], QETInformation::ELMT_SUPPLIER), QString("S2"));
QVERIFY(!m_elements[2]->elementInformations().contains(QETInformation::ELMT_SUPPLIER));
}
// The first live change never joins an earlier change to the
// same elements (here the paste-like command of initTestCase)
void liveEditDoesNotJoinEarlierChange()
{
QMap<QPointer<Element>, QPair<DiagramContext, DiagramContext>> map;
for (Element *e : m_elements) {
DiagramContext info = e->elementInformations();
info.addValue(QETInformation::ELMT_PLANT, "P1");
map.insert(e, qMakePair(e->elementInformations(), info));
}
stack().push(new ChangeElementInformationCommand(map));
const int before = stack().index();
MultiElementInfoWidget w(m_elements);
w.setLiveEdit(true);
w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME");
QCOMPARE(stack().index(), before + 1);
stack().undo();
for (Element *e : m_elements)
QCOMPARE(info(e, QETInformation::ELMT_PLANT), QString("P1"));
}
// An undo from outside the editor shows again what the elements have
void undoRefreshesTheEditor()
{
MultiElementInfoWidget w(m_elements);
w.setLiveEdit(true);
w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME");
QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Set);
stack().undo();
QCoreApplication::processEvents();
QCOMPARE(w.fieldIsMixed(QETInformation::ELMT_SUPPLIER), true);
QCOMPARE(w.fieldState(QETInformation::ELMT_SUPPLIER), MultiElementInfoWidget::Keep);
}
// "Exclude from the bill of materials": partly checked when the
// elements differ, a click sets it on every element
void flagMixedThenSet()
{
MultiElementInfoWidget w(m_elements);
QCheckBox *bom = nullptr;
for (QCheckBox *cb : w.findChildren<QCheckBox *>())
if (cb->text() == QObject::tr("Exclude from the bill of materials"))
bom = cb;
QVERIFY(bom);
QCOMPARE(bom->checkState(), Qt::PartiallyChecked);
bom->click();
w.apply();
for (Element *e : m_elements)
QVERIFY(QET::infoFlagIsTrue(info(e, "exclude_from_bom")));
}
void formulaIdKeptOnlyWithItsFormula()
{
QMap<QPointer<Element>, QPair<DiagramContext, DiagramContext>> map;
for (Element *e : m_elements)
{
DiagramContext info = e->elementInformations();
info.addValue(QETInformation::ELMT_FORMULA, "K%{id}");
info.addValue(QETInformation::ELMT_FORMULA_ID, "{00000000-0000-0000-0000-000000000001}", false);
map.insert(e, qMakePair(e->elementInformations(), info));
}
// Element drops an id naming no numbering of the project;
// what is checked is that the editor itself keeps or drops it
stack().push(new ChangeElementInformationCommand(map));
const bool stored = m_elements[0]->elementInformations().contains(QETInformation::ELMT_FORMULA_ID);
MultiElementInfoWidget w(m_elements);
w.setFieldText(QETInformation::ELMT_SUPPLIER, "ACME");
w.apply();
QCOMPARE(m_elements[0]->elementInformations().contains(QETInformation::ELMT_FORMULA_ID), stored);
MultiElementInfoWidget w2(m_elements);
w2.setFieldText(QETInformation::ELMT_FORMULA, "Q%{id}");
w2.apply();
for (Element *e : m_elements) {
QVERIFY(!e->elementInformations().contains(QETInformation::ELMT_FORMULA_ID));
QCOMPARE(info(e, QETInformation::ELMT_FORMULA), QString("Q%{id}"));
}
}
// The selection panel: one element, several, and a mix
void factoryPicksTheEditor()
{
QList<QGraphicsItem *> one {m_elements[0]};
std::unique_ptr<PropertiesEditorWidget> e1(PropertiesEditorFactory::propertiesEditor(one, nullptr));
QVERIFY(qobject_cast<ElementPropertiesWidget *>(e1.get()));
QList<QGraphicsItem *> two {m_elements[0], m_elements[1]};
std::unique_ptr<PropertiesEditorWidget> e2(PropertiesEditorFactory::propertiesEditor(two, nullptr));
auto multi = qobject_cast<MultiElementInfoWidget *>(e2.get());
QVERIFY(multi);
QCOMPARE(multi->elements().size(), 2);
// Same kind of editor: reused for the new selection
QList<QGraphicsItem *> mix {m_elements[0], m_elements[1], m_elements[2], m_conductor};
PropertiesEditorWidget *e3 = PropertiesEditorFactory::propertiesEditor(mix, e2.get());
QCOMPARE(e3, e2.get());
QCOMPARE(multi->elements().size(), 3);
// Select all takes the elements' own texts: not other items
QList<QGraphicsItem *> with_texts {m_elements[0], m_elements[1]};
for (QGraphicsItem *child : m_elements[0]->childItems())
if (child->type() == DynamicElementTextItem::Type)
with_texts << child;
QVERIFY(with_texts.size() > 2);
QCOMPARE(MultiElementInfoWidget::withoutOwnTexts(with_texts).size(), 2);
// A text alone, or with another element, stays
QList<QGraphicsItem *> other_text {m_elements[1], with_texts.last()};
QCOMPARE(MultiElementInfoWidget::withoutOwnTexts(other_text).size(), 2);
QList<QGraphicsItem *> wire {m_conductor};
std::unique_ptr<PropertiesEditorWidget> e4(PropertiesEditorFactory::propertiesEditor(wire, nullptr));
QVERIFY(!qobject_cast<MultiElementInfoWidget *>(e4.get()));
}
// Edit (Ctrl+E) is offered for several elements, not for a mix
void editActionForSeveralElements()
{
QETDiagramEditor *editor = QetAppTest::editor();
QAction *edit = QetAppTest::action("edit_selected_element");
QVERIFY(edit);
m_diagram->clearSelection();
m_elements[0]->setSelected(true);
m_elements[1]->setSelected(true);
editor->slot_updateComplexActions();
QVERIFY(edit->isEnabled());
// with their own texts, as select all gives them
for (QGraphicsItem *child : m_elements[0]->childItems())
child->setSelected(true);
editor->slot_updateComplexActions();
QVERIFY(edit->isEnabled());
m_conductor->setSelected(true);
editor->slot_updateComplexActions();
QVERIFY(!edit->isEnabled());
m_diagram->clearSelection();
}
// The selection panel builds a new editor when the selection goes
// from one element to several and back: it opens on the tab
// picked last there, not on the first one
void panelKeepsTheTabPicked()
{
auto tabsOf = [](QWidget *w) { return w->findChild<QTabWidget *>(); };
int general = -1;
{
ElementPropertiesWidget first(m_elements[0]);
first.setLiveEdit(true);
QTabWidget *tabs = tabsOf(&first);
QVERIFY(tabs);
for (int i = 0 ; i < tabs->count() ; ++i)
if (tabs->tabText(i) == QObject::tr("General"))
general = i;
QVERIFY(general > 0);
QVERIFY(tabs->currentIndex() != general);
emit tabs->tabBarClicked(general);
tabs->setCurrentIndex(general);
}
ElementPropertiesWidget second(m_elements[1]);
second.setLiveEdit(true);
QCOMPARE(tabsOf(&second)->currentIndex(), general);
// A dialog (not live) still opens on the first tab
ElementPropertiesWidget dialog(m_elements[1]);
QCOMPARE(tabsOf(&dialog)->currentIndex(), 0);
}
// The Reset button of the one-element dialog brings back the
// Information tab's values
void oneElementResetRestores()
{
ElementInfoWidget w(m_elements[0]);
ElementInfoPartWidget *part = nullptr;
for (auto *p : w.findChildren<ElementInfoPartWidget *>())
if (p->key() == QETInformation::ELMT_SUPPLIER)
part = p;
QVERIFY(part);
part->setText("changed");
QCOMPARE(w.currentInfo().value(QETInformation::ELMT_SUPPLIER).toString(), QString("changed"));
w.reset();
QCOMPARE(w.currentInfo().value(QETInformation::ELMT_SUPPLIER).toString(), QString("S1"));
}
};
QET_APP_TEST_MAIN(tst_multielementinfo)
#include "tst_multielementinfo.moc"