Merge master into feat/changed-on-disk-check

Conflicts: QETProject::write() now checks for a change on disk before it
keeps the previous version; both new tests kept in tests/qttest/CMakeLists.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
This commit is contained in:
ispyisail
2026-10-16 17:44:42 +13:00
26 changed files with 1213 additions and 70 deletions
+35
View File
@@ -931,6 +931,30 @@ if(QET_HAS_SCRIPTING)
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# A folio report keeps showing where its partner is when another of its
# information changes (F116), on examples/iso_sfc_example.qet.
add_executable(
tst_reporttextafterinfo
tst_reporttextafterinfo.cpp)
add_test(NAME tst_reporttextafterinfo COMMAND tst_reporttextafterinfo)
add_dependencies(tst_reporttextafterinfo qelectrotech)
target_link_libraries(tst_reporttextafterinfo PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_reporttextafterinfo PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# A .qetz folio whose title only changed keeps its pictures when saved
# with folios built when needed (F118), on examples/grafcet.qet.
add_executable(
tst_qetzheaderpictures
tst_qetzheaderpictures.cpp)
add_test(NAME tst_qetzheaderpictures COMMAND tst_qetzheaderpictures)
add_dependencies(tst_qetzheaderpictures qelectrotech)
target_link_libraries(tst_qetzheaderpictures PRIVATE Qt::Test)
target_compile_definitions(tst_qetzheaderpictures PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# Saving refuses a project file another user or program changed since it was
# opened (QETProject::changedOnDisk()); QET_OVERWRITE_CHANGED=1 overrides.
add_executable(
@@ -1276,6 +1300,17 @@ if(Qt6_VERSION VERSION_LESS 6.5.0)
endforeach()
endif()
# The versions of a project kept before it is saved over: the newest
# kept, same bytes not kept twice, a failed copy said
# (sources/utils/previousversions.*).
add_executable(
tst_previousversions
tst_previousversions.cpp
${QET_DIR}/sources/utils/previousversions.cpp)
add_test(NAME tst_previousversions COMMAND tst_previousversions)
target_include_directories(tst_previousversions PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_previousversions PRIVATE Qt::Test)
# The zip a .qetz project is stored in: round trip, same bytes twice,
# unsafe entry names refused (sources/container/qetzip.*, bundled miniz).
add_executable(
+127
View File
@@ -0,0 +1,127 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QtTest>
#include <QDateTime>
#include <QFile>
#include <QTemporaryDir>
#include "utils/previousversions.h"
// The versions of a project kept before it is saved over
// (sources/utils/previousversions.*)
class tst_previousversions : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// @p path holding @p text, saved at @p when
static void write(const QString &path, const QByteArray &text, const QDateTime &when)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
file.write(text);
file.flush(); //else closing writes, and sets the time to now
QVERIFY(file.setFileTime(when, QFileDevice::FileModificationTime));
}
static QByteArray read(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
private slots:
void initTestCase() { QVERIFY(m_dir.isValid()); }
// Each save keeps the file as it was, newest first, named by the time
// that version was saved, in "<name>-backups" beside it; only the
// newest are kept
void keepsTheNewest()
{
const QString project = m_dir.filePath(QStringLiteral("plant.qetz"));
const QDateTime start(QDate(2026, 10, 16), QTime(8, 0, 0));
for (int i = 0 ; i < 7 ; ++i) {
write(project, "version " + QByteArray::number(i), start.addSecs(60 * i));
QVERIFY(PreviousVersions::keep(project, 3));
}
const QStringList kept = PreviousVersions::kept(project);
QCOMPARE(kept.size(), 3);
QCOMPARE(read(kept.at(0)), QByteArray("version 6"));
QCOMPARE(read(kept.at(2)), QByteArray("version 4"));
QCOMPARE(QFileInfo(kept.at(0)).fileName(), QStringLiteral("plant 2026-10-16 08-06-00.qetz"));
QCOMPARE(QFileInfo(kept.at(0)).absolutePath(), m_dir.filePath(QStringLiteral("plant-backups")));
// The project itself is untouched
QCOMPARE(read(project), QByteArray("version 6"));
}
// Saving the same bytes again keeps no second copy
void sameBytesNotKeptTwice()
{
const QString project = m_dir.filePath(QStringLiteral("same.qet"));
const QDateTime when(QDate(2026, 10, 16), QTime(9, 0, 0));
write(project, "unchanged", when);
QVERIFY(PreviousVersions::keep(project, 5));
write(project, "unchanged", when.addSecs(30));
QVERIFY(PreviousVersions::keep(project, 5));
QCOMPARE(PreviousVersions::kept(project).size(), 1);
}
// Two versions saved the same second both kept
void sameSecond()
{
const QString project = m_dir.filePath(QStringLiteral("fast.qetz"));
const QDateTime when(QDate(2026, 10, 16), QTime(10, 0, 0));
write(project, "first", when);
QVERIFY(PreviousVersions::keep(project, 5));
write(project, "second", when);
QVERIFY(PreviousVersions::keep(project, 5));
QCOMPARE(PreviousVersions::kept(project).size(), 2);
}
// Nothing kept for a new file, or when the count is 0; another
// project's versions in the same folder are not counted
void nothingToKeep()
{
const QString project = m_dir.filePath(QStringLiteral("new.qetz"));
QVERIFY(PreviousVersions::keep(project, 5));
QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project)));
write(project, "x", QDateTime(QDate(2026, 10, 16), QTime(11, 0, 0)));
QVERIFY(PreviousVersions::keep(project, 0));
QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project)));
QVERIFY(PreviousVersions::kept(m_dir.filePath(QStringLiteral("other.qetz"))).isEmpty());
}
// A copy that cannot be made says so, and leaves the project as it is
void copyFails()
{
const QString project = m_dir.filePath(QStringLiteral("blocked.qetz"));
write(project, "data", QDateTime(QDate(2026, 10, 16), QTime(12, 0, 0)));
// A file where the folder should be
write(PreviousVersions::folder(project), "", QDateTime::currentDateTime());
QString error;
QVERIFY(!PreviousVersions::keep(project, 5, &error));
QVERIFY(!error.isEmpty());
QCOMPARE(read(project), QByteArray("data"));
}
};
QTEST_GUILESS_MAIN(tst_previousversions)
#include "tst_previousversions.moc"
+146
View File
@@ -3,6 +3,7 @@
#include <QDir>
#include <QFile>
#include <QtEndian>
#include <functional>
@@ -533,6 +534,38 @@ private slots:
QVERIFY(!error.isEmpty());
}
// A page of the database overwritten, as a bad disk or a broken copy
// would: refused, and said to be damaged (quick_check on opening),
// whichever table the page held
void damagedDatabaseRefused_data()
{
QTest::addColumn<double>("where");
QTest::newRow("second page") << 0.0;
QTest::newRow("middle page") << 0.5;
QTest::newRow("last page") << 1.0;
}
void damagedDatabaseRefused()
{
QFETCH(double, where);
QDomDocument original;
QList<QetZip::Entry> entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original);
bool damaged = false;
for (QetZip::Entry &entry : entries) {
if (entry.name != QLatin1String("project.sqlite")) continue;
const int page_size = qFromBigEndian<quint16>(entry.data.constData() + 16);
const int pages = int(entry.data.size() / page_size);
QVERIFY(pages > 3);
const int page = 1 + int(where * (pages - 2));
entry.data.replace(page * page_size, page_size, QByteArray(page_size, char(0xff)));
damaged = true;
}
QVERIFY(damaged);
QDomDocument back;
QString error;
QVERIFY(!QetContainer::join(entries, &back, &error));
QVERIFY2(error.contains(QStringLiteral("damaged")), qPrintable(error));
}
// project.sqlite changed by @p sql in @p entries
static void alterDatabase(QList<QetZip::Entry> &entries, const char *sql)
{
@@ -764,6 +797,119 @@ private slots:
QString error;
QVERIFY(!QetContainer::join({{QStringLiteral("mimetype"), "application/zip", false}}, &back, &error));
}
// recover() reads what it can of a damaged .qetz: each kind of damage
// costs what it held and nothing else, and is said
void recoverLeavesOutOnlyTheDamage_data()
{
QTest::addColumn<QString>("damage");
QTest::addColumn<QString>("said");
QTest::addColumn<int>("folios");
QTest::addColumn<int>("wires");
QTest::newRow("nothing") << QString() << QString() << 0 << 0;
QTest::newRow("folio file missing") << QStringLiteral("folio") << QStringLiteral("is missing") << -1 << 0;
QTest::newRow("wire row deleted") << QStringLiteral("row") << QStringLiteral("not in the database") << 0 << -1;
QTest::newRow("database page overwritten") << QStringLiteral("page") << QStringLiteral("cannot be read") << 0 << -1000;
}
void recoverLeavesOutOnlyTheDamage()
{
QFETCH(QString, damage);
QFETCH(QString, said);
QFETCH(int, folios);
QFETCH(int, wires);
QDomDocument original;
QList<QetZip::Entry> entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original);
QVERIFY(!entries.isEmpty());
auto count = [](const QDomDocument &d, const QString &tag) {
return int(d.documentElement().elementsByTagName(tag).size());
};
const int folios_before = count(original, QStringLiteral("diagram"));
const int wires_before = count(original, QStringLiteral("conductor"));
if (damage == QLatin1String("folio")) {
for (int i = 0 ; i < entries.size() ; ++i)
if (entries.at(i).name.startsWith(QStringLiteral("folios/"))) { entries.removeAt(i); break; }
} else if (damage == QLatin1String("row")) {
alterDatabase(entries, "DELETE FROM conductor WHERE rowid = (SELECT min(rowid) FROM conductor)");
} else if (damage == QLatin1String("page")) {
for (QetZip::Entry &entry : entries) {
if (entry.name != QLatin1String("project.sqlite")) continue;
const int page_size = qFromBigEndian<quint16>(entry.data.constData() + 16);
const int page = int(entry.data.size() / page_size / 2);
entry.data.replace(page * page_size, page_size, QByteArray(page_size, char(0xff)));
}
}
QByteArray zip;
QString error;
QVERIFY2(QetZip::toBytes(entries, &error).size() > 0, qPrintable(error));
zip = QetZip::toBytes(entries, &error);
QDomDocument back;
QStringList lost;
QVERIFY2(QetContainer::recover(zip, &back, &lost, &error), qPrintable(error));
if (said.isEmpty()) {
QVERIFY2(lost.isEmpty(), qPrintable(lost.join(QLatin1Char('\n'))));
} else {
QVERIFY2(lost.join(QLatin1Char('\n')).contains(said), qPrintable(lost.join(QLatin1Char('\n'))));
// Strict reading refuses it
QDomDocument strict;
QVERIFY(!QetContainer::join(entries, &strict));
}
QCOMPARE(count(back, QStringLiteral("diagram")), folios_before + folios);
if (wires == -1000)
QCOMPARE(count(back, QStringLiteral("conductor")), 0);
else
QCOMPARE(count(back, QStringLiteral("conductor")), wires_before + wires);
// Nothing of the container's own left in the document
QVERIFY(!back.toString().contains(QStringLiteral("qetz-")));
}
// A picture's file missing (F118 wrote such projects): the picture is
// left out, the rest read
void recoverMissingPicture()
{
QList<QetZip::Entry> entries;
QString error;
QVERIFY2(QetZip::fromBytes(read(QStringLiteral(QET_FIXTURES_DIR "/qetz_format2.qetz")), &entries, &error),
qPrintable(error));
QDomDocument document;
QVERIFY2(QetContainer::join(entries, &document, &error), qPrintable(error));
// A picture on the first folio, then saved and its file dropped
QDomElement folio = document.documentElement().firstChildElement(QStringLiteral("diagram"));
QDomElement images = folio.firstChildElement(QStringLiteral("images"));
if (images.isNull()) images = folio.appendChild(document.createElement(QStringLiteral("images"))).toElement();
QDomElement image = document.createElement(QStringLiteral("image"));
image.setAttribute(QStringLiteral("type"), QStringLiteral("png"));
image.appendChild(document.createTextNode(QStringLiteral(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")));
images.appendChild(image);
QList<QetZip::Entry> saved = QetContainer::split(document, QString(), &error);
QVERIFY2(!saved.isEmpty(), qPrintable(error));
bool dropped = false;
for (int i = 0 ; i < saved.size() ; ++i)
if (saved.at(i).name.startsWith(QStringLiteral("images/"))) { saved.removeAt(i); dropped = true; break; }
QVERIFY(dropped);
QDomDocument strict;
QVERIFY(!QetContainer::join(saved, &strict, &error));
QVERIFY(error.contains(QStringLiteral("picture")));
QDomDocument back;
QStringList lost;
QVERIFY2(QetContainer::recover(QetZip::toBytes(saved), &back, &lost, &error), qPrintable(error));
QCOMPARE(lost.size(), 1);
QVERIFY(lost.first().contains(QStringLiteral("picture")));
QCOMPARE(int(back.documentElement().elementsByTagName(QStringLiteral("image")).size()), 0);
QVERIFY(back.toString().contains(QStringLiteral("<elementInformation ")));
}
// What cannot be read as a zip at all is still refused
void recoverNotAZipRefused()
{
QDomDocument back;
QStringList lost;
QString error;
QVERIFY(!QetContainer::recover(QByteArray("not a zip").repeated(10), &back, &lost, &error));
QVERIFY(!error.isEmpty());
}
};
QTEST_APPLESS_MAIN(tst_qetcontainer)
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// A folio of a .qetz whose title only changed is saved from its items as
// read, not built (Diagram::deferredXmlWithHeader()). Those items name
// their pictures' files in the .qetz, which the new .qetz left out: the
// project saved then refused to open, 'picture "images/..." is missing'
// (FINDINGS F118). Runs scripts through the real binary's --run on a copy
// of examples/grafcet.qet given a picture on its second folio.
class tst_qetzheaderpictures : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// Run @p script on @p project; its output
QString run(const QString &script, const QString &project, bool every_folio_built)
{
const QString path = m_dir.filePath(QStringLiteral("script.js"));
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return QString();
f.write(script.toUtf8());
f.close();
const QString home = m_dir.filePath(QStringLiteral("home"));
QDir().mkpath(home);
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"));
env.insert(QStringLiteral("TMPDIR"), m_dir.path());
if (every_folio_built)
env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1"));
else
env.remove(QStringLiteral("QET_EAGER_FOLIOS"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--run"), path, project});
proc.waitForFinished(120000);
return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError());
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void titleChangeKeepsPictures()
{
// A 1x1 PNG
const QString png = m_dir.filePath(QStringLiteral("dot.png"));
QFile p(png);
QVERIFY(p.open(QIODevice::WriteOnly));
p.write(QByteArray::fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="));
p.close();
const QString with_picture = m_dir.filePath(QStringLiteral("picture.qetz"));
const QString titled = m_dir.filePath(QStringLiteral("titled.qetz"));
QString out = run(QStringLiteral("qet.log('ADDED ' + qet.addImage(1, '%1', 100, 100));"
"qet.save('%2');").arg(png, with_picture),
QStringLiteral(QET_EXAMPLES_DIR "/grafcet.qet"), true);
QVERIFY2(out.contains(QStringLiteral("ADDED 0")), qPrintable(out));
QVERIFY(QFile::exists(with_picture));
// Only the title of the folio with the picture, folios built
// when needed
out = run(QStringLiteral("qet.log('SET ' + qet.setFolioProperty(1, 'title', 'Edited'));"
"qet.save('%1');").arg(titled), with_picture, false);
QVERIFY2(out.contains(QStringLiteral("SET true")), qPrintable(out));
QVERIFY(QFile::exists(titled));
// Opens, with its picture and the new title
out = run(QStringLiteral("qet.log('PICTURES ' + qet.images(1).length);"
"qet.log('TITLE ' + qet.folioTitle(1));"), titled, true);
QVERIFY2(out.contains(QStringLiteral("PICTURES 1")), qPrintable(out));
QVERIFY2(out.contains(QStringLiteral("TITLE Edited")), qPrintable(out));
}
};
QTEST_APPLESS_MAIN(tst_qetzheaderpictures)
#include "tst_qetzheaderpictures.moc"
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QDomDocument>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// A folio report shows where its partner is. Changing any other
// information of the report wrote back the label stored in its
// information, the partner's position when the file was last saved, so
// the report pointed at the wrong place until the partner moved again
// (FINDINGS F116). Runs a script through the real binary's --run on
// examples/iso_sfc_example.qet: the "jump to" of folio 1 moves from
// column 4 to column 9, then the "jump from" on folio 2 gets a comment.
class tst_reporttextafterinfo : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// The text a symbol @p uuid of @p saved shows for its label
static QString labelText(const QString &saved, const QString &uuid)
{
QFile file(saved);
if (!file.open(QIODevice::ReadOnly)) return QStringLiteral("(not saved)");
QDomDocument doc;
if (!doc.setContent(&file)) return QStringLiteral("(not read)");
const QDomNodeList elements = doc.elementsByTagName(QStringLiteral("element"));
for (int i = 0 ; i < elements.size() ; ++i) {
const QDomElement e = elements.at(i).toElement();
if (e.attribute(QStringLiteral("uuid")) != uuid) continue;
const QDomNodeList texts = e.elementsByTagName(QStringLiteral("dynamic_elmt_text"));
for (int j = 0 ; j < texts.size() ; ++j) {
const QDomElement t = texts.at(j).toElement();
if (t.firstChildElement(QStringLiteral("info_name")).text() == QLatin1String("label"))
return t.firstChildElement(QStringLiteral("text")).text();
}
}
return QStringLiteral("(no such text)");
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void reportFollowsItsPartner_data()
{
QTest::addColumn<bool>("every_folio_built");
QTest::newRow("folios built when needed") << false;
QTest::newRow("every folio built") << true;
}
void reportFollowsItsPartner()
{
QFETCH(bool, every_folio_built);
const QString tag = every_folio_built ? QStringLiteral("eager") : QStringLiteral("lazy");
const QString saved = m_dir.filePath(tag + QStringLiteral(".qet"));
const QString script = m_dir.filePath(tag + QStringLiteral(".js"));
QFile f(script);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write("qet.moveElement(0, '{bcf3d2e9-7190-48ed-bbb5-57bcc9ba8cfa}', 300, 0);\n"
"qet.setElementInfo(1, '{016d0266-33ac-4255-8fe2-4d9629a12d86}', 'comment', 'edited');\n"
"qet.save('" + saved.toUtf8() + "');\n");
f.close();
// Columns numbered from 1, as QElectroTech's settings page
// shows by default
const QString home = m_dir.filePath(tag + QStringLiteral("-home"));
QVERIFY(QDir().mkpath(home + QStringLiteral("/config/QElectroTech")));
QFile conf(home + QStringLiteral("/config/QElectroTech/QElectroTech.conf"));
QVERIFY(conf.open(QIODevice::WriteOnly));
conf.write("[General]\nborder-columns_0=false\n");
conf.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"));
env.insert(QStringLiteral("TMPDIR"), m_dir.path());
if (every_folio_built)
env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1"));
else
env.remove(QStringLiteral("QET_EAGER_FOLIOS"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--run"), script,
QStringLiteral(QET_EXAMPLES_DIR "/iso_sfc_example.qet")});
QVERIFY(proc.waitForFinished(120000));
// The partner, moved, and the report that shows where it is
QCOMPARE(labelText(saved, QStringLiteral("{016d0266-33ac-4255-8fe2-4d9629a12d86}")),
QStringLiteral("1-C9"));
}
};
QTEST_APPLESS_MAIN(tst_reporttextafterinfo)
#include "tst_reporttextafterinfo.moc"