Files
qelectrotech-source-mirror/tests/qttest/tst_multielementinfo.cpp
T
ispyisail ce0798bb18 Add editing the information of several elements at once (#1439)
Selecting several elements left the Selection properties panel blank and
Edit (Ctrl+E) disabled, so giving many elements the same supplier or
manufacturer meant editing them one by one.

The panel now shows the Information fields for two or more elements, or
for elements selected with other items (left unchanged, and counted in
the panel's header). Ctrl+E opens the same editor as a dialog when only
elements are selected.

- A field shows the value every element shares, or "Multiple values".
  Left alone, each element keeps its own value; typed text goes to every
  element; the erase box clears the field on every element.
- A restore button per field, and Reset, bring back the values the
  elements had.
- One apply is one undo step. The keystrokes typed in the panel merge
  into one step but never into an earlier change of the same elements
  (a paste of element information, another edit).
- Numbering scheme, label freezing and the number picker are left out:
  they are about one element. Custom properties are kept as they are.

Also:
- An element's own texts, selected with it by Select all, count as part
  of the element, not as other items.
- ElementInfoWidget::reset(): Reset in the element dialog now brings back
  the Information tab's values; it did nothing.
- The panel opens an element's editor on the tab picked last, instead of
  the first tab, after the selection went to something else and back.

Test: tst_multielementinfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:44:00 +13:00

413 lines
15 KiB
C++

// 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"