From c956ba27a6e17035ec710c0899c70d2ae963abd2 Mon Sep 17 00:00:00 2001 From: Timothy Schoen Date: Sat, 13 Jun 2026 13:56:54 +0200 Subject: [PATCH 01/55] ELSE update --- Libraries/pd-else | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/pd-else b/Libraries/pd-else index 76cfe03a9..6b5036db6 160000 --- a/Libraries/pd-else +++ b/Libraries/pd-else @@ -1 +1 @@ -Subproject commit 76cfe03a9c0b202d1317674029ceec85c3b1b4a2 +Subproject commit 6b5036db6761668b0ef6705e2cae2ab6a148b75b From 6019a7da7b707b6aafdfd5979991a83c1a10d571 Mon Sep 17 00:00:00 2001 From: Timothy Schoen Date: Thu, 18 Jun 2026 13:08:08 +0200 Subject: [PATCH 02/55] Update tests --- Source/Canvas.cpp | 16 +- Source/Components/MarkupDisplay.h | 6 +- Source/Dialogs/Deken.h | 57 +- Source/Dialogs/Dialogs.cpp | 6 + Source/Dialogs/PatchStore.h | 51 +- Source/Dialogs/TextEditorDialog.h | 196 +-- Source/PluginEditor.cpp | 14 +- Source/PluginProcessor.cpp | 3 +- Source/TabComponent.cpp | 8 + Source/Utility/Decompress.h | 9 +- Source/Utility/SettingsFile.cpp | 12 + Tests/AbstractionReloadRaceTest.h | 130 ++ Tests/CommandInputTest.h | 193 +++ Tests/CoverageSweepTest.h | 2498 +++++++++++++++++++++++++++++ Tests/DanglingBindingTest.h | 109 ++ Tests/DecompressTest.h | 110 ++ Tests/DialogCoverageTest.h | 126 ++ Tests/DraggableNumberTest.h | 144 ++ Tests/EditActionStressTest.h | 225 +++ Tests/EditWorkflowTest.h | 298 ++++ Tests/ExportProgressTest.h | 202 +++ Tests/HelpfileErrorTest.h | 2 +- Tests/InspectorTest.h | 146 ++ Tests/LuaObjectTest.h | 309 ++++ Tests/MarkupDisplayTest.h | 111 ++ Tests/ObjectFuzzTest.h | 2 +- Tests/ObjectInteractionTest.h | 254 +++ Tests/OnboardingTest.h | 120 ++ Tests/PatchTextFuzzTest.h | 166 ++ Tests/SaveCloseRaceTest.h | 71 + Tests/SmallUITest.h | 151 ++ Tests/StateRoundTripTest.h | 82 + Tests/StoreDekenTest.h | 289 ++++ Tests/TabSplitStressTest.h | 149 ++ Tests/Tests.cpp | 106 +- Tests/Tests.h | 83 + Tests/TextEditorDialogTest.h | 388 +++++ Tests/UIClickThroughTest.h | 141 ++ 38 files changed, 6827 insertions(+), 156 deletions(-) create mode 100644 Tests/AbstractionReloadRaceTest.h create mode 100644 Tests/CommandInputTest.h create mode 100644 Tests/CoverageSweepTest.h create mode 100644 Tests/DanglingBindingTest.h create mode 100644 Tests/DecompressTest.h create mode 100644 Tests/DialogCoverageTest.h create mode 100644 Tests/DraggableNumberTest.h create mode 100644 Tests/EditActionStressTest.h create mode 100644 Tests/EditWorkflowTest.h create mode 100644 Tests/ExportProgressTest.h create mode 100644 Tests/InspectorTest.h create mode 100644 Tests/LuaObjectTest.h create mode 100644 Tests/MarkupDisplayTest.h create mode 100644 Tests/ObjectInteractionTest.h create mode 100644 Tests/OnboardingTest.h create mode 100644 Tests/PatchTextFuzzTest.h create mode 100644 Tests/SaveCloseRaceTest.h create mode 100644 Tests/SmallUITest.h create mode 100644 Tests/StateRoundTripTest.h create mode 100644 Tests/StoreDekenTest.h create mode 100644 Tests/TabSplitStressTest.h create mode 100644 Tests/TextEditorDialogTest.h create mode 100644 Tests/UIClickThroughTest.h diff --git a/Source/Canvas.cpp b/Source/Canvas.cpp index 6e8bf531b..4d0933ef4 100644 --- a/Source/Canvas.cpp +++ b/Source/Canvas.cpp @@ -1590,8 +1590,12 @@ void Canvas::deselectAll(bool const broadcastChange) s->hideParameters(); if (!broadcastChange) { - // Add back the listener, but make sure it's added back 'after' the last event on the message queue - MessageManager::callAsync([this] { selectedComponents.addChangeListener(this); }); + // Add back the listener, but make sure it's added back 'after' the last event on the message queue. + // Guard with a SafePointer: the canvas may be deleted (e.g. its tab closed) before this runs. + MessageManager::callAsync([_this = SafePointer(this)] { + if (_this) + _this->selectedComponents.addChangeListener(_this); + }); } } @@ -2559,8 +2563,12 @@ void Canvas::setSelected(Component* component, bool const shouldNowBeSelected, b } if (!broadcastChange) { - // Add back the listener, but make sure it's added back 'after' the last event on the message queue - MessageManager::callAsync([this] { selectedComponents.addChangeListener(this); }); + // Add back the listener, but make sure it's added back 'after' the last event on the message queue. + // Guard with a SafePointer: the canvas may be deleted (e.g. its tab closed) before this runs. + MessageManager::callAsync([_this = SafePointer(this)] { + if (_this) + _this->selectedComponents.addChangeListener(_this); + }); } } diff --git a/Source/Components/MarkupDisplay.h b/Source/Components/MarkupDisplay.h index 8385f3087..011ae7fa2 100644 --- a/Source/Components/MarkupDisplay.h +++ b/Source/Components/MarkupDisplay.h @@ -630,10 +630,10 @@ class TableBlock final : public Block { for (int i = 0; i < table.cells.size(); i++) { OwnedArray const* row = table.cells[i]; for (int j = 0; j < row->size(); j++) { - if (j < table.columnwidths.size()) { + if (j < static_cast(table.columnwidths.size())) { table.columnwidths[j] = jmax(table.columnwidths[j], (*row)[j]->width); } else { - table.columnwidths[j] = (*row)[j]->width; + table.columnwidths.add((*row)[j]->width); } } } @@ -645,7 +645,7 @@ class TableBlock final : public Block { for (int j = 0; j < row->size(); j++) { rowheight = jmax(rowheight, (*row)[j]->height); } - table.rowheights[i] = rowheight; + table.rowheights.add(rowheight); } table.setBounds(0, 0, getWidthRequired() + table.leftmargin + table.cellgap, getHeightRequired(0.f)); } diff --git a/Source/Dialogs/Deken.h b/Source/Dialogs/Deken.h index c1352d75b..539106757 100644 --- a/Source/Dialogs/Deken.h +++ b/Source/Dialogs/Deken.h @@ -96,7 +96,13 @@ class PackageManager final : public Thread .withConnectionTimeoutMs(10000) .withStatusCode(&statusCode)); - if (instream != nullptr && statusCode == 200) { + bool downloadOk = instream != nullptr && statusCode == 200; +#if ENABLE_TESTING + // Local file:// archives used by the test suite don't report an + // HTTP status, so accept any readable stream while testing + downloadOk = instream != nullptr; +#endif + if (downloadOk) { startThread(); } else { finish(Result::fail("Failed to start download")); @@ -228,24 +234,37 @@ class PackageManager final : public Thread PackageList getAvailablePackages() { + MemoryBlock block; - // plugdata's deken servers, hosted on GitHub - // This will pre-parse the deken repo information to a faster and smaller format - // This saves a lot of work that plugdata would have to do on startup! - - auto const triplet = os + "-" + machine + "-" + floatsize; - auto const repoForArchitecture = "https://raw.githubusercontent.com/plugdata-team/plugdata-deken/main/bin/" + triplet + ".bin"; - - webstream = std::make_unique(URL(repoForArchitecture), false); - webstream->connect(nullptr); - - if (webstream->isError()) { +#if ENABLE_TESTING + // The test suite injects a serialised package tree (or forces a failure) + // here, so no network request is made during testing + if (mockShouldFail) { sendActionMessage("Failed to connect to server"); return { }; } + if (mockPackageData.getSize() > 0) { + block = mockPackageData; + } else +#endif + { + // plugdata's deken servers, hosted on GitHub + // This will pre-parse the deken repo information to a faster and smaller format + // This saves a lot of work that plugdata would have to do on startup! - MemoryBlock block; - webstream->readIntoMemoryBlock(block); + auto const triplet = os + "-" + machine + "-" + floatsize; + auto const repoForArchitecture = "https://raw.githubusercontent.com/plugdata-team/plugdata-deken/main/bin/" + triplet + ".bin"; + + webstream = std::make_unique(URL(repoForArchitecture), false); + webstream->connect(nullptr); + + if (webstream->isError()) { + sendActionMessage("Failed to connect to server"); + return { }; + } + + webstream->readIntoMemoryBlock(block); + } // Parse tree that was downloaded auto const tree = ValueTree::readFromData(block.getData(), block.getSize()); @@ -345,7 +364,15 @@ class PackageManager final : public Thread PackageList allPackages; +#if ENABLE_TESTING + // The test suite installs into a temp dir and injects mock catalog data, + // so neither the network nor the user's real Externals folder is touched + static inline File filesystem = ProjectInfo::appDataDir.getChildFile("Externals"); + static inline MemoryBlock mockPackageData; + static inline bool mockShouldFail = false; +#else static inline File const filesystem = ProjectInfo::appDataDir.getChildFile("Externals"); +#endif // Package info file File pkgInfo = filesystem.getChildFile(".pkg_info"); @@ -409,7 +436,9 @@ class PackageManager final : public Thread JUCE_DECLARE_SINGLETON(PackageManager, false) }; +#ifndef PLUGDATA_TEST_TRANSLATION_UNIT // implemented in Dialogs.cpp's TU JUCE_IMPLEMENT_SINGLETON(PackageManager) +#endif class Deken final : public Component , public ListBoxModel diff --git a/Source/Dialogs/Dialogs.cpp b/Source/Dialogs/Dialogs.cpp index ae38f0144..018405ce9 100644 --- a/Source/Dialogs/Dialogs.cpp +++ b/Source/Dialogs/Dialogs.cpp @@ -937,6 +937,9 @@ void Dialogs::dismissFileDialog() void Dialogs::showOpenDialog(std::function const& callback, bool const canSelectFiles, bool const canSelectDirectories, String const& extension, String const& lastFileId, Component* parentComponent) { +#if ENABLE_TESTING + return; // Don't open file dialogs during testing +#endif bool nativeDialog = SettingsFile::getInstance()->wantsNativeDialog(); auto initialFile = lastFileId.isNotEmpty() ? SettingsFile::getInstance()->getLastBrowserPathForId(lastFileId) : ProjectInfo::appDataDir; if (!initialFile.exists()) @@ -980,6 +983,9 @@ void Dialogs::showOpenDialog(std::function const& callback, bool cons void Dialogs::showSaveDialog(std::function const& callback, String const& extension, String const& lastFileId, Component* parentComponent, bool const directoryMode, String const& defaultFileName) { +#if ENABLE_TESTING + return; // Don't open file dialogs during testing +#endif bool nativeDialog = SettingsFile::getInstance()->wantsNativeDialog(); auto initialFile = lastFileId.isNotEmpty() ? SettingsFile::getInstance()->getLastBrowserPathForId(lastFileId) : ProjectInfo::appDataDir; if (!initialFile.exists()) diff --git a/Source/Dialogs/PatchStore.h b/Source/Dialogs/PatchStore.h index be39664cc..72d31dd2d 100644 --- a/Source/Dialogs/PatchStore.h +++ b/Source/Dialogs/PatchStore.h @@ -47,10 +47,12 @@ class DownloadPool final : public DeletedAtShutdown { cancelImageDownloads(); imagePool.addJob([this] { SmallArray patches; - int statusCode = 0; - auto const webstream = URL("https://plugdata.org/store.json").createInputStream(URL::InputStreamOptions(URL::ParameterHandling::inAddress).withConnectionTimeoutMs(10000).withStatusCode(&statusCode)); - if (!webstream || statusCode >= 400) { + String jsonString; +#if ENABLE_TESTING + // The test suite injects mock catalog JSON (or forces a failure) + // here, so no network request is made during testing + if (mockStoreShouldFail) { MessageManager::callAsync([this] { for (auto& listener : listeners) { listener->databaseDownloadFailed(); @@ -58,18 +60,36 @@ class DownloadPool final : public DeletedAtShutdown { }); return; } + if (mockStoreJson.isNotEmpty()) { + jsonString = mockStoreJson; + } else +#endif + { + int statusCode = 0; + auto const webstream = URL("https://plugdata.org/store.json").createInputStream(URL::InputStreamOptions(URL::ParameterHandling::inAddress).withConnectionTimeoutMs(10000).withStatusCode(&statusCode)); - MemoryBlock jsonData; - MemoryOutputStream mo(jsonData, false); + if (!webstream || statusCode >= 400) { + MessageManager::callAsync([this] { + for (auto& listener : listeners) { + listener->databaseDownloadFailed(); + } + }); + return; + } - mo.preallocate(32000); // fit store.json file with some extra space - while (true) { - auto const written = mo.writeFromInputStream(*webstream, 1 << 14); - if (written == 0) - break; + MemoryBlock jsonData; + MemoryOutputStream mo(jsonData, false); + + mo.preallocate(32000); // fit store.json file with some extra space + while (true) { + auto const written = mo.writeFromInputStream(*webstream, 1 << 14); + if (written == 0) + break; + } + jsonString = jsonData.toString(); // Converting to string is important on Windows to get correct character encoding } - auto const parsedData = JSON::parse(jsonData.toString()); // Converting to string is important on Windows to get correct character encoding + auto const parsedData = JSON::parse(jsonString); auto patchData = parsedData["Patches"]; if (patchData.isArray()) { for (int i = 0; i < patchData.size(); ++i) { @@ -265,10 +285,19 @@ class DownloadPool final : public DeletedAtShutdown { std::atomic cancelledImageDownload = false; public: +#if ENABLE_TESTING + // The test suite injects mock catalog JSON (or forces a failure) so the + // store database can be exercised without any network access + static inline String mockStoreJson; + static inline bool mockStoreShouldFail = false; +#endif + JUCE_DECLARE_SINGLETON(DownloadPool, false); }; +#ifndef PLUGDATA_TEST_TRANSLATION_UNIT // implemented in Dialogs.cpp's TU JUCE_IMPLEMENT_SINGLETON(DownloadPool); +#endif class OnlineImage final : public Component , public DownloadPool::DownloadListener { diff --git a/Source/Dialogs/TextEditorDialog.h b/Source/Dialogs/TextEditorDialog.h index 0f686364b..72794403b 100644 --- a/Source/Dialogs/TextEditorDialog.h +++ b/Source/Dialogs/TextEditorDialog.h @@ -1370,26 +1370,26 @@ class PlugDataTextEditor final : public Component { // IMPLEMENTATIONS -Caret::Caret(TextDocument const& document) +inline Caret::Caret(TextDocument const& document) : document(document) { setInterceptsMouseClicks(false, false); startTimerHz(20); } -void Caret::setViewTransform(AffineTransform const& transformToUse) +inline void Caret::setViewTransform(AffineTransform const& transformToUse) { transform = transformToUse; repaint(); } -void Caret::updateSelections() +inline void Caret::updateSelections() { phase = 0.f; repaint(); } -void Caret::paint(Graphics& g) +inline void Caret::paint(Graphics& g) { g.setColour(getParentComponent()->findColour(CaretComponent::caretColourId).withAlpha(squareWave(phase))); @@ -1397,14 +1397,14 @@ void Caret::paint(Graphics& g) g.fillRect(r); } -float Caret::squareWave(float const wt) +inline float Caret::squareWave(float const wt) { constexpr float delta = 0.222f; constexpr float A = 1.0; return 0.5f + A / 3.14159f * std::atan(std::cos(wt) / delta); } -void Caret::timerCallback() +inline void Caret::timerCallback() { phase += 3.2e-1; @@ -1412,7 +1412,7 @@ void Caret::timerCallback() repaint(r.getSmallestIntegerContainer()); } -SmallArray> Caret::getCaretRectangles() const +inline SmallArray> Caret::getCaretRectangles() const { SmallArray> rectangles; @@ -1429,18 +1429,18 @@ SmallArray> Caret::getCaretRectangles() const return rectangles; } -GutterComponent::GutterComponent(TextDocument const& document) +inline GutterComponent::GutterComponent(TextDocument const& document) : document(document) { setInterceptsMouseClicks(false, false); } -void GutterComponent::updateSelections() +inline void GutterComponent::updateSelections() { repaint(); } -void GutterComponent::paint(Graphics& g) +inline void GutterComponent::paint(Graphics& g) { auto const ln = PlugDataColours::sidebarBackgroundColour; auto const scaleFactor = std::sqrt(std::abs(transform.getDeterminant())); @@ -1480,19 +1480,19 @@ void GutterComponent::paint(Graphics& g) } } -HighlightComponent::HighlightComponent(TextDocument const& document) +inline HighlightComponent::HighlightComponent(TextDocument const& document) : document(document) { setInterceptsMouseClicks(false, false); } -void HighlightComponent::setViewTransform(AffineTransform const& transformToUse, SmallArray const& selections) +inline void HighlightComponent::setViewTransform(AffineTransform const& transformToUse, SmallArray const& selections) { transform = transformToUse; updateSelections(selections, lastMainSelection); } -void HighlightComponent::updateSelections(SmallArray const& selections, int mainSelection) +inline void HighlightComponent::updateSelections(SmallArray const& selections, int mainSelection) { lastMainSelection = mainSelection; outlinePath.clear(); @@ -1512,7 +1512,7 @@ void HighlightComponent::updateSelections(SmallArray const& selection repaint(outlinePath.getBounds().getSmallestIntegerContainer()); } -void HighlightComponent::paint(Graphics& g) +inline void HighlightComponent::paint(Graphics& g) { g.addTransform(transform); @@ -1526,7 +1526,7 @@ void HighlightComponent::paint(Graphics& g) g.strokePath(outlinePath, PathStrokeType(1.f)); } -Path HighlightComponent::getOutlinePath(SmallArray> const& rectangles) +inline Path HighlightComponent::getOutlinePath(SmallArray> const& rectangles) { auto p = Path(); auto rect = rectangles.begin(); @@ -1551,7 +1551,7 @@ Path HighlightComponent::getOutlinePath(SmallArray> const& rect return p.createPathWithRoundedCorners(4.f); } -Selection::Selection(String const& content) +inline Selection::Selection(String const& content) { int rowSpan = 0; int n = 0, lastLineStart = 0; @@ -1570,12 +1570,12 @@ Selection::Selection(String const& content) tail = { rowSpan, content.length() - lastLineStart }; } -bool Selection::isOriented() const +inline bool Selection::isOriented() const { return !(head.x > tail.x || (head.x == tail.x && head.y > tail.y)); } -Selection Selection::oriented() const +inline Selection Selection::oriented() const { if (!isOriented()) return swapped(); @@ -1583,14 +1583,14 @@ Selection Selection::oriented() const return *this; } -Selection Selection::swapped() const +inline Selection Selection::swapped() const { Selection s = *this; std::swap(s.head, s.tail); return s; } -Selection Selection::horizontallyMaximized(TextDocument const& document) const +inline Selection Selection::horizontallyMaximized(TextDocument const& document) const { Selection s = *this; @@ -1604,7 +1604,7 @@ Selection Selection::horizontallyMaximized(TextDocument const& document) const return s; } -Selection Selection::measuring(String const& content) const +inline Selection Selection::measuring(String const& content) const { Selection s(content); @@ -1614,7 +1614,7 @@ Selection Selection::measuring(String const& content) const return Selection(content).startingFrom(tail).swapped(); } -Selection Selection::startingFrom(Point const index) const +inline Selection Selection::startingFrom(Point const index) const { Selection s = *this; @@ -1631,19 +1631,19 @@ Selection Selection::startingFrom(Point const index) const return s; } -void Selection::pullBy(Selection disappearingSelection) +inline void Selection::pullBy(Selection disappearingSelection) { disappearingSelection.pull(head); disappearingSelection.pull(tail); } -void Selection::pushBy(Selection appearingSelection) +inline void Selection::pushBy(Selection appearingSelection) { appearingSelection.push(head); appearingSelection.push(tail); } -void Selection::pull(Point& index) const +inline void Selection::pull(Point& index) const { auto const S = oriented(); @@ -1670,7 +1670,7 @@ void Selection::pull(Point& index) const } } -void Selection::push(Point& index) const +inline void Selection::push(Point& index) const { auto const S = oriented(); @@ -1696,7 +1696,7 @@ void Selection::push(Point& index) const } } -String const& GlyphArrangementArray::operator[](int const index) const +inline String const& GlyphArrangementArray::operator[](int const index) const { if (isPositiveAndBelow(index, lines.size())) { return lines[index].string; @@ -1706,7 +1706,7 @@ String const& GlyphArrangementArray::operator[](int const index) const return empty; } -int GlyphArrangementArray::getToken(int const row, int const col, int const defaultIfOutOfBounds) const +inline int GlyphArrangementArray::getToken(int const row, int const col, int const defaultIfOutOfBounds) const { if (!isPositiveAndBelow(row, lines.size())) { return defaultIfOutOfBounds; @@ -1714,12 +1714,12 @@ int GlyphArrangementArray::getToken(int const row, int const col, int const defa return lines[row].tokens[col]; } -bool GlyphArrangementArray::isNewLine(int index) const +inline bool GlyphArrangementArray::isNewLine(int index) const { return lines[index].isNewLine; } -void GlyphArrangementArray::clearTokens(int const index) +inline void GlyphArrangementArray::clearTokens(int const index) { if (!isPositiveAndBelow(index, lines.size())) return; @@ -1733,7 +1733,7 @@ void GlyphArrangementArray::clearTokens(int const index) } } -void GlyphArrangementArray::applyTokens(int const index, Selection zone) +inline void GlyphArrangementArray::applyTokens(int const index, Selection zone) { if (!isPositiveAndBelow(index, lines.size())) return; @@ -1748,7 +1748,7 @@ void GlyphArrangementArray::applyTokens(int const index, Selection zone) } } -GlyphArrangement GlyphArrangementArray::getGlyphs(int const index, +inline GlyphArrangement GlyphArrangementArray::getGlyphs(int const index, float const baseline, int const token, bool const withTrailingSpace) const @@ -1778,7 +1778,7 @@ GlyphArrangement GlyphArrangementArray::getGlyphs(int const index, return glyphs; } -void GlyphArrangementArray::ensureValid(int const index) const +inline void GlyphArrangementArray::ensureValid(int const index) const { if (!isPositiveAndBelow(index, lines.size())) return; @@ -1793,7 +1793,7 @@ void GlyphArrangementArray::ensureValid(int const index) const } } -void GlyphArrangementArray::invalidateAll() +inline void GlyphArrangementArray::invalidateAll() { for (auto& entry : lines) { entry.glyphsAreDirty = true; @@ -1801,7 +1801,7 @@ void GlyphArrangementArray::invalidateAll() } } -void TextDocument::setMaximumLineWidth(int maxWidth, float viewScaleFactor) +inline void TextDocument::setMaximumLineWidth(int maxWidth, float viewScaleFactor) { maxCharWidth = (maxWidth - 12 - (48.f * viewScaleFactor)) / (7.052f * viewScaleFactor); auto caretOffset = getCaretOffset(); @@ -1810,7 +1810,7 @@ void TextDocument::setMaximumLineWidth(int maxWidth, float viewScaleFactor) setCaretOffset(caretOffset); } -SmallArray TextDocument::breakLine(String line) +inline SmallArray TextDocument::breakLine(String line) { SmallArray lines; @@ -1826,7 +1826,7 @@ SmallArray TextDocument::breakLine(String line) return lines; } -void TextDocument::replaceAll(String const& content) +inline void TextDocument::replaceAll(String const& content) { lines.clear(); @@ -1837,7 +1837,7 @@ void TextDocument::replaceAll(String const& content) } } -String TextDocument::getText() const +inline String TextDocument::getText() const { String text; for (int i = 0; i < lines.size(); i++) { @@ -1850,17 +1850,17 @@ String TextDocument::getText() const return text.trimCharactersAtEnd("\n\r"); } -int TextDocument::getNumRows() const +inline int TextDocument::getNumRows() const { return lines.size(); } -int TextDocument::getNumColumns(int const row) const +inline int TextDocument::getNumColumns(int const row) const { return lines[row].length(); } -float TextDocument::getVerticalPosition(int const row, Metric const metric) const +inline float TextDocument::getVerticalPosition(int const row, Metric const metric) const { float const lineHeight = font.getHeight() * lineSpacing; float const gap = font.getHeight() * (lineSpacing - 1.f) * 0.5f; @@ -1881,12 +1881,12 @@ float TextDocument::getVerticalPosition(int const row, Metric const metric) cons } } -Point TextDocument::getPosition(Point const index, Metric const metric) const +inline Point TextDocument::getPosition(Point const index, Metric const metric) const { return { getGlyphBounds(index).getX(), getVerticalPosition(index.x, metric) }; } -SmallArray> TextDocument::getSelectionRegion(Selection const selection, Rectangle const clip) const +inline SmallArray> TextDocument::getSelectionRegion(Selection const selection, Rectangle const clip) const { SmallArray> patches; Selection const s = selection.oriented(); @@ -1918,7 +1918,7 @@ SmallArray> TextDocument::getSelectionRegion(Selection const se return patches; } -Rectangle TextDocument::getBounds() const +inline Rectangle TextDocument::getBounds() const { if (cachedBounds.isEmpty()) { auto bounds = Rectangle(); @@ -1931,7 +1931,7 @@ Rectangle TextDocument::getBounds() const return cachedBounds; } -Rectangle TextDocument::getBoundsOnRow(int const row, Range const columns) const +inline Rectangle TextDocument::getBoundsOnRow(int const row, Range const columns) const { return getGlyphsForRow(row, -1, true) .getBoundingBox(columns.getStart(), columns.getLength(), true) @@ -1939,13 +1939,13 @@ Rectangle TextDocument::getBoundsOnRow(int const row, Range const co .withBottom(getVerticalPosition(row, Metric::bottom)); } -Rectangle TextDocument::getGlyphBounds(Point index) const +inline Rectangle TextDocument::getGlyphBounds(Point index) const { index.y = jlimit(0, getNumColumns(index.x), index.y); return getBoundsOnRow(index.x, Range(index.y, index.y + 1)); } -GlyphArrangement TextDocument::getGlyphsForRow(int const row, int const token, bool const withTrailingSpace) const +inline GlyphArrangement TextDocument::getGlyphsForRow(int const row, int const token, bool const withTrailingSpace) const { return lines.getGlyphs(row, getVerticalPosition(row, Metric::baseline), @@ -1953,7 +1953,7 @@ GlyphArrangement TextDocument::getGlyphsForRow(int const row, int const token, b withTrailingSpace); } -GlyphArrangement TextDocument::findGlyphsIntersecting(Rectangle const area, int const token) const +inline GlyphArrangement TextDocument::findGlyphsIntersecting(Rectangle const area, int const token) const { auto const range = getRangeOfRowsIntersecting(area); auto rows = SmallArray(); @@ -1965,7 +1965,7 @@ GlyphArrangement TextDocument::findGlyphsIntersecting(Rectangle const are return glyphs; } -Range TextDocument::getRangeOfRowsIntersecting(Rectangle const area) const +inline Range TextDocument::getRangeOfRowsIntersecting(Rectangle const area) const { auto const lineHeight = font.getHeight() * lineSpacing; auto row0 = jlimit(0, jmax(getNumRows() - 1, 0), static_cast(area.getY() / lineHeight)); @@ -1973,7 +1973,7 @@ Range TextDocument::getRangeOfRowsIntersecting(Rectangle const area) return { row0, row1 + 1 }; } -SmallArray TextDocument::findRowsIntersecting(Rectangle const area, +inline SmallArray TextDocument::findRowsIntersecting(Rectangle const area, bool const computeHorizontalExtent) const { auto const range = getRangeOfRowsIntersecting(area); @@ -2010,7 +2010,7 @@ SmallArray TextDocument::findRowsIntersecting(Rectangle TextDocument::findIndexNearestPosition(Point const position) const +inline Point TextDocument::findIndexNearestPosition(Point const position) const { auto const lineHeight = font.getHeight() * lineSpacing; auto row = jlimit(0, jmax(getNumRows() - 1, 0), static_cast(position.y / lineHeight)); @@ -2030,12 +2030,12 @@ Point TextDocument::findIndexNearestPosition(Point const position) c return { row, col }; } -Point TextDocument::getEnd() const +inline Point TextDocument::getEnd() const { return { getNumRows(), 0 }; } -bool TextDocument::next(Point& index) const +inline bool TextDocument::next(Point& index) const { if (index.y < getNumColumns(index.x)) { index.y += 1; @@ -2049,7 +2049,7 @@ bool TextDocument::next(Point& index) const return false; } -bool TextDocument::prev(Point& index) const +inline bool TextDocument::prev(Point& index) const { if (index.y > 0) { index.y -= 1; @@ -2063,7 +2063,7 @@ bool TextDocument::prev(Point& index) const return false; } -bool TextDocument::nextRow(Point& index) const +inline bool TextDocument::nextRow(Point& index) const { if (index.x < getNumRows()) { index.x += 1; @@ -2073,7 +2073,7 @@ bool TextDocument::nextRow(Point& index) const return false; } -bool TextDocument::prevRow(Point& index) const +inline bool TextDocument::prevRow(Point& index) const { if (index.x > 0) { index.x -= 1; @@ -2083,7 +2083,7 @@ bool TextDocument::prevRow(Point& index) const return false; } -void TextDocument::navigate(Point& i, Target const target, Direction const direction) const +inline void TextDocument::navigate(Point& i, Target const target, Direction const direction) const { std::function&)> advance; std::function&)> get; @@ -2153,7 +2153,7 @@ void TextDocument::navigate(Point& i, Target const target, Direction const } } -void TextDocument::navigateSelections(Target const target, Direction const direction, Selection::Part const part) +inline void TextDocument::navigateSelections(Target const target, Direction const direction, Selection::Part const part) { auto isHeadBeforeTail = [](Point const head, Point const tail) -> int { if (head.x == tail.x) @@ -2185,7 +2185,7 @@ void TextDocument::navigateSelections(Target const target, Direction const direc } } -void TextDocument::search(String const& text, bool clearCurrent) +inline void TextDocument::search(String const& text, bool clearCurrent) { lastSearch = text; selections.clear(); @@ -2202,7 +2202,7 @@ void TextDocument::search(String const& text, bool clearCurrent) currentSearchSelection = -1; } -juce_wchar TextDocument::getCharacter(Point const index) const +inline juce_wchar TextDocument::getCharacter(Point const index) const { jassert(0 <= index.x && index.x <= lines.size()); jassert(0 <= index.y && index.y <= lines[index.x].length()); @@ -2213,22 +2213,22 @@ juce_wchar TextDocument::getCharacter(Point const index) const return lines[index.x].getCharPointer()[index.y]; } -Selection const& TextDocument::getSelection(int const index) const +inline Selection const& TextDocument::getSelection(int const index) const { return selections[index]; } -SmallArray const& TextDocument::getSelections() const +inline SmallArray const& TextDocument::getSelections() const { return selections; } -SmallArray const& TextDocument::getSearchSelections() const +inline SmallArray const& TextDocument::getSearchSelections() const { return searchSelections; } -String TextDocument::getSelectionContent(Selection s) const +inline String TextDocument::getSelectionContent(Selection s) const { s = s.oriented(); @@ -2245,7 +2245,7 @@ String TextDocument::getSelectionContent(Selection s) const return content; } -Transaction TextDocument::fulfill(Transaction const& transaction) +inline Transaction TextDocument::fulfill(Transaction const& transaction) { cachedBounds = { }; // invalidate the bounds @@ -2285,14 +2285,14 @@ Transaction TextDocument::fulfill(Transaction const& transaction) return r; } -void TextDocument::clearTokens(Range const rows) +inline void TextDocument::clearTokens(Range const rows) { for (int n = rows.getStart(); n < rows.getEnd(); ++n) { lines.clearTokens(n); } } -void TextDocument::applyTokens(Range const rows, SmallArray const& zones) +inline void TextDocument::applyTokens(Range const rows, SmallArray const& zones) { for (int n = rows.getStart(); n < rows.getEnd(); ++n) { for (auto const& zone : zones) { @@ -2330,7 +2330,7 @@ class Transaction::Undoable final : public UndoableAction { Transaction reverse; }; -Transaction Transaction::accountingForSpecialCharacters(TextDocument const& document) const +inline Transaction Transaction::accountingForSpecialCharacters(TextDocument const& document) const { Transaction t = *this; auto& s = t.selection; @@ -2352,12 +2352,12 @@ Transaction Transaction::accountingForSpecialCharacters(TextDocument const& docu return t; } -UndoableAction* Transaction::on(TextDocument& document, Callback callback) +inline UndoableAction* Transaction::on(TextDocument& document, Callback callback) { return new Undoable(document, std::move(callback), *this); } -PlugDataTextEditor::PlugDataTextEditor() +inline PlugDataTextEditor::PlugDataTextEditor() : caret(document) , gutter(document) , highlight(document) @@ -2380,37 +2380,37 @@ PlugDataTextEditor::PlugDataTextEditor() lookAndFeelChanged(); } -void PlugDataTextEditor::lookAndFeelChanged() +inline void PlugDataTextEditor::lookAndFeelChanged() { highlight.setHighlightColour(findColour(CodeEditorComponent::highlightColourId)); searchHighlight.setHighlightColour(Colours::yellow.withAlpha(0.5f)); } -void PlugDataTextEditor::paintOverChildren(Graphics& g) +inline void PlugDataTextEditor::paintOverChildren(Graphics& g) { g.setColour(PlugDataColours::toolbarOutlineColour); g.drawHorizontalLine(0, 0, getWidth()); g.drawHorizontalLine(getHeight() - 1, 0, getWidth()); } -void PlugDataTextEditor::setFont(Font const& font) +inline void PlugDataTextEditor::setFont(Font const& font) { document.setFont(font); repaint(); } -void PlugDataTextEditor::setText(String const& text) +inline void PlugDataTextEditor::setText(String const& text) { document.replaceAll(text); repaint(); } -String PlugDataTextEditor::getText() const +inline String PlugDataTextEditor::getText() const { return document.getText(); } -void PlugDataTextEditor::translateView(float const dy) +inline void PlugDataTextEditor::translateView(float const dy) { auto const H = viewScaleFactor * document.getBounds().getHeight(); @@ -2420,7 +2420,7 @@ void PlugDataTextEditor::translateView(float const dy) updateViewTransform(); } -bool PlugDataTextEditor::scaleView(float const scaleFactor, float const verticalCenter, bool absolute) +inline bool PlugDataTextEditor::scaleView(float const scaleFactor, float const verticalCenter, bool absolute) { auto const oldS = viewScaleFactor; auto targetScale = absolute ? scaleFactor : viewScaleFactor * scaleFactor; @@ -2444,7 +2444,7 @@ bool PlugDataTextEditor::scaleView(float const scaleFactor, float const vertical return false; } -void PlugDataTextEditor::updateViewTransform() +inline void PlugDataTextEditor::updateViewTransform() { transform = AffineTransform::scale(viewScaleFactor).translated(translation.x, translation.y); highlight.setViewTransform(transform, document.getSelections()); @@ -2454,7 +2454,7 @@ void PlugDataTextEditor::updateViewTransform() repaint(); } -void PlugDataTextEditor::updateSelections() +inline void PlugDataTextEditor::updateSelections() { highlight.updateSelections(document.getSelections()); searchHighlight.updateSelections(document.getSearchSelections(), document.getCurrentSearchSelection().first); @@ -2464,7 +2464,7 @@ void PlugDataTextEditor::updateSelections() parent->repaint(); } -void PlugDataTextEditor::translateToEnsureCaretIsVisible() +inline void PlugDataTextEditor::translateToEnsureCaretIsVisible() { auto const i = document.getSelections().back().head; auto const t = Point(0.f, document.getVerticalPosition(i.x, TextDocument::Metric::top)).transformedBy(transform); @@ -2477,7 +2477,7 @@ void PlugDataTextEditor::translateToEnsureCaretIsVisible() } } -void PlugDataTextEditor::translateToEnsureSearchIsVisible(int const index) +inline void PlugDataTextEditor::translateToEnsureSearchIsVisible(int const index) { auto selections = document.getSearchSelections(); if (index >= selections.size()) @@ -2494,7 +2494,7 @@ void PlugDataTextEditor::translateToEnsureSearchIsVisible(int const index) } } -void PlugDataTextEditor::resized() +inline void PlugDataTextEditor::resized() { highlight.setBounds(getLocalBounds()); searchHighlight.setBounds(getLocalBounds()); @@ -2504,7 +2504,7 @@ void PlugDataTextEditor::resized() updateSelections(); } -void PlugDataTextEditor::paint(Graphics& g) +inline void PlugDataTextEditor::paint(Graphics& g) { g.fillAll(PlugDataColours::canvasBackgroundColour); @@ -2588,7 +2588,7 @@ void PlugDataTextEditor::paint(Graphics& g) } } -Rectangle PlugDataTextEditor::getScrollBarBounds() const +inline Rectangle PlugDataTextEditor::getScrollBarBounds() const { auto const contentHeight = document.getHeight(); auto const visibleHeight = getHeight(); @@ -2602,7 +2602,7 @@ Rectangle PlugDataTextEditor::getScrollBarBounds() const return { getWidth() - 10.f, scrollbarPosition + 2, 8.0f, scrollbarHeight - 4 }; } -void PlugDataTextEditor::mouseDown(MouseEvent const& e) +inline void PlugDataTextEditor::mouseDown(MouseEvent const& e) { if (e.getNumberOfClicks() > 1) { return; @@ -2651,7 +2651,7 @@ void PlugDataTextEditor::mouseDown(MouseEvent const& e) updateSelections(); } -void PlugDataTextEditor::mouseDrag(MouseEvent const& e) +inline void PlugDataTextEditor::mouseDrag(MouseEvent const& e) { // Check if the drag is happening within the scrollbar area (right 10px of the editor) if (e.getMouseDownX() > getWidth() - 10 && document.getHeight() > getHeight()) { @@ -2668,13 +2668,13 @@ void PlugDataTextEditor::mouseDrag(MouseEvent const& e) } } -void PlugDataTextEditor::mouseUp(MouseEvent const& e) +inline void PlugDataTextEditor::mouseUp(MouseEvent const& e) { scrollBarClicked = false; repaint(); } -void PlugDataTextEditor::mouseMove(MouseEvent const& e) +inline void PlugDataTextEditor::mouseMove(MouseEvent const& e) { if (e.x > getWidth() - 10 && document.getHeight() > getHeight() && !isOverScrollBar) { isOverScrollBar = true; @@ -2685,14 +2685,14 @@ void PlugDataTextEditor::mouseMove(MouseEvent const& e) } } -void PlugDataTextEditor::mouseExit(MouseEvent const& e) +inline void PlugDataTextEditor::mouseExit(MouseEvent const& e) { if (isOverScrollBar) { isOverScrollBar = false; growAnimator.start(); } } -void PlugDataTextEditor::mouseDoubleClick(MouseEvent const& e) +inline void PlugDataTextEditor::mouseDoubleClick(MouseEvent const& e) { if (e.getNumberOfClicks() == 2) { document.navigateSelections(TextDocument::Target::whitespace, TextDocument::Direction::backwardCol, Selection::Part::head); @@ -2706,7 +2706,7 @@ void PlugDataTextEditor::mouseDoubleClick(MouseEvent const& e) updateSelections(); } -void PlugDataTextEditor::mouseWheelMove(MouseEvent const& e, MouseWheelDetails const& d) +inline void PlugDataTextEditor::mouseWheelMove(MouseEvent const& e, MouseWheelDetails const& d) { if (e.mods.isCommandDown()) { magnifyScaleFactor = std::clamp(magnifyScaleFactor * 1.0f + d.deltaY, 0.8f, 1.2f); @@ -2719,7 +2719,7 @@ void PlugDataTextEditor::mouseWheelMove(MouseEvent const& e, MouseWheelDetails c translateView(d.deltaY * 800); } -void PlugDataTextEditor::mouseMagnify(MouseEvent const& e, float const scaleFactor) +inline void PlugDataTextEditor::mouseMagnify(MouseEvent const& e, float const scaleFactor) { magnifyScaleFactor = std::clamp(magnifyScaleFactor * (((scaleFactor - 1.0f) * 0.8f) + 1.0f), 0.8f, 1.2f); if (scaleView(magnifyScaleFactor, e.position.y)) { @@ -2727,7 +2727,7 @@ void PlugDataTextEditor::mouseMagnify(MouseEvent const& e, float const scaleFact } } -bool PlugDataTextEditor::keyPressed(KeyPress const& key) +inline bool PlugDataTextEditor::keyPressed(KeyPress const& key) { using Target = TextDocument::Target; using Direction = TextDocument::Direction; @@ -2851,7 +2851,7 @@ bool PlugDataTextEditor::keyPressed(KeyPress const& key) return false; } -bool PlugDataTextEditor::insert(String const& content) +inline bool PlugDataTextEditor::insert(String const& content) { double const now = Time::getApproximateMillisecondCounter(); @@ -2888,7 +2888,7 @@ bool PlugDataTextEditor::insert(String const& content) return true; } -MouseCursor PlugDataTextEditor::getMouseCursor() +inline MouseCursor PlugDataTextEditor::getMouseCursor() { if (isOverScrollBar) return MouseCursor::NormalCursor; @@ -2896,7 +2896,7 @@ MouseCursor PlugDataTextEditor::getMouseCursor() return getMouseXYRelative().x < (48.f * viewScaleFactor) && getMouseXYRelative().x > getWidth() - 10 ? MouseCursor::NormalCursor : MouseCursor::IBeamCursor; } -CodeEditorComponent::ColourScheme PlugDataTextEditor::getSyntaxColourScheme() +inline CodeEditorComponent::ColourScheme PlugDataTextEditor::getSyntaxColourScheme() { auto const textColour = PlugDataColours::canvasTextColour; if (PlugDataColours::canvasBackgroundColour.getPerceivedBrightness() > 0.5f) { @@ -2941,7 +2941,7 @@ CodeEditorComponent::ColourScheme PlugDataTextEditor::getSyntaxColourScheme() return cs; } -void PlugDataTextEditor::setSearchText(String const& searchText) +inline void PlugDataTextEditor::setSearchText(String const& searchText) { document.search(searchText, true); updateSelections(); @@ -2950,7 +2950,7 @@ void PlugDataTextEditor::setSearchText(String const& searchText) } } -void PlugDataTextEditor::searchNext() +inline void PlugDataTextEditor::searchNext() { auto const next = document.searchNext(); updateSelections(); diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 7a28af226..de9597c94 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -2173,7 +2173,16 @@ Object* PluginEditor::highlightSearchTarget(void* target, bool const openNewTabI return nullptr; } - auto calculateDimensionsAndProcess = [cnv, found, viewport] { + auto calculateDimensionsAndProcess = [_cnv = Component::SafePointer(cnv), _found = Component::SafePointer(found)] { + auto* cnv = _cnv.getComponent(); + auto* found = _found.getComponent(); + if (!cnv || !found) + return; + + auto* viewport = cnv->viewport.get(); + if (!viewport) + return; + auto const scale = getValue(cnv->zoomScale); if (!viewport->getBoundsInParent().contains(found->getBounds())) { @@ -2203,9 +2212,6 @@ Object* PluginEditor::highlightSearchTarget(void* target, bool const openNewTabI } }; - // FIXME: We have to wait for the viewport to call internally resized before the size is correct! - // So we check if the bounds are 0,0,0,0, and if so, post the calculations to the message thread - // which _should_ place this event after the resize 🙏 if (viewport->getBoundsInParent().isEmpty()) MessageManager::callAsync(calculateDimensionsAndProcess); else diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 3fed6b5c5..ff11f5e63 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -1586,7 +1586,8 @@ pd::Patch::Ptr PluginProcessor::loadPatch(String patchText) auto patch = loadPatch(URL(patchFile)); // Set to unknown file when loading temp patch - patch->setCurrentFile(URL("file://")); + if (patch) + patch->setCurrentFile(URL("file://")); return patch; } diff --git a/Source/TabComponent.cpp b/Source/TabComponent.cpp index fbdecc1bf..f5857e6b3 100644 --- a/Source/TabComponent.cpp +++ b/Source/TabComponent.cpp @@ -365,6 +365,8 @@ void TabComponent::openPatch(const URL& path) url.setBookmarkData(patchPath.getBookmarkData()); #endif auto const patch = pd->loadPatch(url); + if (!patch) + return; // If we're opening a temp file, assume it's dirty upon opening // This is so that you can recover an autosave without directly overewriting it, but still be prompted to save if you close the autosaved patch @@ -385,6 +387,8 @@ void TabComponent::openPatch(const URL& path) Canvas* TabComponent::openPatch(String const& patchContent) { auto const patch = pd->loadPatch(patchContent); + if (!patch) + return nullptr; patch->setUntitled(); return openPatch(patch); } @@ -604,6 +608,8 @@ void TabComponent::nextTab() { auto const splitIndex = activeSplitIndex && splits[1]; auto const& tabbar = tabbars[splitIndex]; + if(!tabbar.size()) return; + auto oldTabIndex = 0; for (int i = 0; i < tabbar.size(); i++) { if (tabbar[i]->cnv == splits[splitIndex]) { @@ -619,6 +625,8 @@ void TabComponent::previousTab() { auto const splitIndex = activeSplitIndex && splits[1]; auto const& tabbar = tabbars[splitIndex]; + if(!tabbar.size()) return; + auto oldTabIndex = 0; for (int i = 0; i < tabbar.size(); i++) { if (tabbar[i]->cnv == splits[splitIndex]) { diff --git a/Source/Utility/Decompress.h b/Source/Utility/Decompress.h index 15bedf6ab..79d35bdb6 100644 --- a/Source/Utility/Decompress.h +++ b/Source/Utility/Decompress.h @@ -221,6 +221,14 @@ struct Decompress { #endif } else if (typeFlag == '0' || typeFlag == '\0') { // Regular file + size_t fileOffset = offset + 512; + + // Reject entries whose declared size extends past the end of + // the archive, so corrupt headers can't make us read out of bounds + if (fileSize > size - fileOffset) { + return false; + } + fs::create_directories(outPath.parent_path()); std::ofstream out(outPath, std::ios::binary); @@ -228,7 +236,6 @@ struct Decompress { return false; } - size_t fileOffset = offset + 512; out.write(reinterpret_cast(data + fileOffset), fileSize); if (!out.good()) { diff --git a/Source/Utility/SettingsFile.cpp b/Source/Utility/SettingsFile.cpp index 2cb71c09a..9e4fd610c 100644 --- a/Source/Utility/SettingsFile.cpp +++ b/Source/Utility/SettingsFile.cpp @@ -205,6 +205,18 @@ SettingsFile* SettingsFile::initialise() isInitialised = true; +#if ENABLE_TESTING + // Run the test suite against a disposable copy of the user's settings: + // tests get realistic settings to read, but every write goes to a temp + // file, so the real settings.json is never modified + auto const testSettingsFile = File::createTempFile(".json"); + if (settingsFile.existsAsFile()) + settingsFile.copyFileTo(testSettingsFile); + settingsFile = testSettingsFile; + lockFile = settingsFile.getSiblingFile(settingsFile.getFileNameWithoutExtension() + ".lock"); + oldSettingsFile = File(); +#endif + FileSystemWatcher::addGlobalIgnorePath(lockFile); FileSystemWatcher::addGlobalIgnorePath(settingsFile); diff --git a/Tests/AbstractionReloadRaceTest.h b/Tests/AbstractionReloadRaceTest.h new file mode 100644 index 000000000..40e819b0d --- /dev/null +++ b/Tests/AbstractionReloadRaceTest.h @@ -0,0 +1,130 @@ +// Regression test for a use-after-free in Canvas::performSynchronise(). +// +// Crash signature (v0.9.2, macOS): SIGABRT "pointer being freed was not +// allocated" with this stack: +// +// juce::Component::~Component() <- free(childComponentList) +// Canvas::performSynchronise() <- "remove deleted objects" loop +// PluginProcessor::reloadAbstractions() +// pd::Patch::savePatch()::$_0 <- async reload after save +// +// Mechanism: +// 1. Saving an abstraction queues reloadAbstractions(), and canvas_reload() +// deletes + recreates every instance of that abstraction. A tab showing the +// *inside* of an old instance is left wrapping a dead t_glist, and every +// Object on that canvas has a dead pd pointer. +// 2. reloadAbstractions() synchronises all canvases. On the dead canvas, +// performSynchronise() destroys the dead Objects inline. +// 3. If one of them is a subpatch, ~SubpatchObject() calls +// closeOpenedSubpatchers(), which finds the tab to close by comparing +// pd::Patch::operator==. That compares getRawPointer(), which returns +// nullptr for *every* dead patch, so nullptr == nullptr makes the dying +// subpatch match the first canvas whose patch is dead: the canvas that is +// currently mid-performSynchronise. closeTab() then deletes that Canvas +// synchronously, destroying all of its pool-allocated Objects (including +// the one whose destructor we are inside of), and the loop resumes on a +// freed `this`, re-destroying already-destroyed Objects. +// +// The arrangement below makes step 3 deterministic: the abstraction instance +// sits inside [pd holder], and the holder view is never opened, so the dying +// instance object is not on any open canvas. (If it were, that canvas's +// earlier synchronise pass would destroy it first and close the dead +// abstraction view through the SafePointer-guarded path in +// reloadAbstractions(), hiding the bug. The instance view is opened by +// looking the instance glist up directly, because opening and closing a +// holder view would also close the instance view: ~SubpatchObject() of the +// holder's instance object closes views of its subpatch even when the pd +// object is still alive.) +// +// Run with AddressSanitizer: the PooledPtrArray poisoning makes ASAN report +// the use-after-free the moment the deleted Canvas/Objects are touched again. +// Without ASAN this aborts in libmalloc, like the production crash. + +class AbstractionReloadRaceTest : public PlugDataUnitTest +{ +public: + AbstractionReloadRaceTest(PluginEditor* editor) : PlugDataUnitTest(editor, "Abstraction Reload Race Test") + { + } + +private: + void perform() override + { + beginTest("Canvas deleted from within its own synchronise after abstraction reload"); + + auto const dir = File::getSpecialLocation(File::tempDirectory); + + // An abstraction containing a plain subpatch: when an instance dies, the + // canvas showing its inside destroys a dead SubpatchObject, whose + // destructor calls closeOpenedSubpatchers() + abstractionFile = dir.getChildFile("reload_race_abs.pd"); + abstractionFile.replaceWithText( + "#N canvas 100 100 450 300 12;\n" + "#N canvas 30 30 200 140 sub 0;\n" + "#X obj 40 40 +~;\n" + "#X restore 60 60 pd sub;\n"); + + // The parent patch, instantiating the abstraction inside [pd holder] + parentFile = dir.getChildFile("reload_race_parent.pd"); + parentFile.replaceWithText( + "#N canvas 100 100 450 300 12;\n" + "#N canvas 30 30 200 140 holder 0;\n" + "#X obj 40 40 reload_race_abs;\n" + "#X restore 60 60 pd holder;\n"); + + auto* pd = editor->pd; + auto& tabbar = editor->getTabComponent(); + + auto* parentCnv = tabbar.openPatch(pd->loadPatch(URL(parentFile))); + if (!parentCnv || parentCnv->objects.empty()) { + signalDone(false); + return; + } + + // Find the abstraction instance's glist inside [pd holder] directly, and + // open a view of it, without ever opening a view of the holder itself + t_glist* instanceGlist = nullptr; + if (auto parentPatch = parentCnv->patch.getPointer()) { + for (t_gobj* holder = parentPatch->gl_list; holder; holder = holder->g_next) { + if (holder->g_pd != canvas_class) + continue; + for (t_gobj* instance = reinterpret_cast(holder)->gl_list; instance; instance = instance->g_next) { + if (instance->g_pd == canvas_class) + instanceGlist = reinterpret_cast(instance); + } + } + } + if (!instanceGlist) { + signalDone(false); + return; + } + + auto* instanceCnv = tabbar.openPatch(pd::Patch::Ptr(new pd::Patch(pd::WeakReference(instanceGlist, pd), pd, false))); + if (!instanceCnv || instanceCnv->objects.empty()) { + signalDone(false); + return; + } + + // Open the abstraction's own file and save it, like a user editing an + // abstraction. savePatch() queues reloadAbstractions() asynchronously; + // the saved root canvas itself is excluded from the reload, but the + // instance inside [pd holder] is recreated, killing the instance view's + // glist + auto* abstractionCnv = tabbar.openPatch(pd->loadPatch(URL(abstractionFile))); + if (!abstractionCnv) { + signalDone(false); + return; + } + abstractionCnv->refCountedPatch->savePatch(); + + // The reload lambda queued by savePatch() is delivered before this one + // (FIFO). Surviving its delivery is the actual test. + MessageManager::callAsync([this] { + abstractionFile.deleteFile(); + parentFile.deleteFile(); + signalDone(true); + }); + } + + File abstractionFile, parentFile; +}; diff --git a/Tests/CommandInputTest.h b/Tests/CommandInputTest.h new file mode 100644 index 000000000..fddd4b0c1 --- /dev/null +++ b/Tests/CommandInputTest.h @@ -0,0 +1,193 @@ +#include "Sidebar/Console.h" +#include "Sidebar/CommandInput.h" +#include "Components/SuggestionComponent.h" + +// Covers the command-line input bar and the object autocomplete popup. +// +// CommandInput: builds a canvas with named objects, then drives executeCommand +// with the full command vocabulary (sel/select by index + name + out-of-range, +// deselect/>, ls/list, find/search, clear, reset, cnv/canvas, man for every +// command, help, " > " object messaging, raw pd messages) plus +// invalid commands and empty queries. History navigation and the helper UI are +// driven through the real TextEditor child and arrow keys. +// +// SuggestionComponent: opens an empty object's editor (which shows the popup), +// then queries it with a prefix that matches (autocomplete + list), one that +// matches nothing (empty result set), navigates the list with the arrow keys, +// and accepts a completion - mirroring what a user typing an object name sees. + +class CommandInputTest : public PlugDataUnitTest +{ +public: + CommandInputTest(PluginEditor* editor) : PlugDataUnitTest(editor, "Command Input Test") + { + } + +private: + static KeyPress key(int const keyCode) { return KeyPress(keyCode, ModifierKeys(), 0); } + + void perform() override + { + testCommandInput(); + // SuggestionComponent needs a laid-out, showing canvas, so run it after + // a tick on the message thread + Timer::callAfterDelay(50, [this] { testSuggestions(); }); + } + + void testCommandInput() + { + beginTest("Command input vocabulary"); + + auto* cnv = editor->getTabComponent().openPatch(String( + "#N canvas 100 100 600 400 12;\n" + "#X obj 50 50 osc~ 440;\n" + "#X obj 50 100 metro 250;\n" + "#X obj 50 150 tgl 25 0 empty empty empty 17 7 0 10 #fcfcfc #000000 #000000 0 1;\n" + "#X msg 50 200 bang;\n")); + cnv->performSynchronise(); + + auto commandInput = std::make_unique(editor); + editor->addAndMakeVisible(commandInput.get()); + commandInput->setBounds(0, 0, 300, 60); + + auto* pd = editor->pd; + + // Each of these returns its result rather than throwing; we mainly want + // them all to execute without crashing, but check the observable ones + commandInput->executeCommand(pd, "ls"); + commandInput->executeCommand(pd, "list"); + commandInput->executeCommand(pd, "find osc"); + commandInput->executeCommand(pd, "search metro"); + commandInput->executeCommand(pd, "search no_such_object_xyz"); // empty result set + + // Selection by index, by name, out of range, and missing argument + commandInput->executeCommand(pd, "sel 0"); + expect(!cnv->getSelectionOfType().empty() || true, "select by index must run"); + commandInput->executeCommand(pd, "select 999"); // out of bounds + commandInput->executeCommand(pd, "sel"); // no argument -> error + commandInput->executeCommand(pd, "deselect"); + commandInput->executeCommand(pd, ">"); // shorthand deselect + + // Canvas + pd messages + commandInput->executeCommand(pd, "cnv"); + commandInput->executeCommand(pd, "pd dsp 0"); + + // man for every documented command, plus an unknown one and help + for (auto const* cmd : { "man", "help", "script", "pd", "cnv", "canvas", + "clear", "reset", "sel", "select", "deselect", + ">", "ls", "list", "find", "search", "bogus_command" }) { + commandInput->executeCommand(pd, String("man ") + cmd); + } + commandInput->executeCommand(pd, "help"); + commandInput->executeCommand(pd, "?"); + + // Object messaging: " > " + commandInput->executeCommand(pd, "tgl_1 > 1"); + commandInput->executeCommand(pd, "no_such_id > 1"); // no object found + commandInput->executeCommand(pd, "osc~_1 >"); // select-only form + + // Invalid / empty + commandInput->executeCommand(pd, ""); + commandInput->executeCommand(pd, " "); + commandInput->executeCommand(pd, "this is not a command"); + + // Reset and clear (clear wipes history + console) + commandInput->executeCommand(pd, "reset"); + commandInput->executeCommand(pd, "clear"); + + // --- History navigation through the real TextEditor + arrow keys --- + if (auto* textEditor = TestHelpers::findChildOfType(commandInput.get())) { + auto runCommand = [&](String const& text) { + textEditor->setText(text, false); + if (textEditor->onReturnKey) + textEditor->onReturnKey(); + }; + runCommand("ls"); + runCommand("deselect"); + runCommand("help"); + + // Up walks back into history, down walks forward + commandInput->keyPressed(key(KeyPress::upKey), textEditor); + auto const firstRecalled = textEditor->getText(); + expect(firstRecalled.isNotEmpty(), "up arrow must recall a history entry"); + commandInput->keyPressed(key(KeyPress::upKey), textEditor); + commandInput->keyPressed(key(KeyPress::downKey), textEditor); + commandInput->keyPressed(key(KeyPress::downKey), textEditor); + commandInput->keyPressed(key(KeyPress::downKey), textEditor); // past the start + + // Other handled keys + commandInput->keyPressed(key(KeyPress::spaceKey), textEditor); + commandInput->keyPressed(key(KeyPress::backspaceKey), textEditor); + } else { + expect(false, "command input must contain a TextEditor"); + } + + // Focus change toggles helper buttons + commandInput->globalFocusChanged(commandInput.get()); + commandInput->globalFocusChanged(nullptr); + + editor->removeChildComponent(commandInput.get()); + commandInput.reset(); + + auto& tabbar = editor->getTabComponent(); + while (auto* c = tabbar.getCurrentCanvas()) + tabbar.closeTab(c); + } + + void testSuggestions() + { + beginTest("Object autocomplete suggestions"); + + auto* cnv = editor->getTabComponent().newPatch(); + if (!cnv) { + signalDone(false); + return; + } + cnv->locked.setValue(false); + + // Create an empty object; its editor opens and shows the suggestion popup + auto* object = cnv->objects.add(cnv, "", Point(100, 100)); + cnv->setSelected(object, true); + + Timer::callAfterDelay(50, [this, cnv, object] { + auto* suggestor = cnv->suggestor.get(); + auto* textEditor = TestHelpers::findChildOfType(object); + if (!suggestor || !textEditor) { + expect(false, "the empty object must open an editor with a suggestion popup"); + finishSuggestions(cnv); + return; + } + + // A prefix that matches many objects: should populate suggestions + // and offer an autocompletion + suggestor->updateSuggestions("osc"); + expect(suggestor->getText().isEmpty() || suggestor->getText().startsWith("osc"), + "autocomplete text must extend the typed prefix"); + + // Different queries exercise the query/ranking and detail paths + suggestor->updateSuggestions("metro"); + suggestor->createComponentSnapshot(suggestor->getLocalBounds()); + + // A query that matches nothing: empty result set + suggestor->updateSuggestions("zzzz_no_such_object_qwerty"); + + // A query matching a known object, then the detail panel state + suggestor->updateSuggestions("print"); + suggestor->isShowingDetailPanel(); + + finishSuggestions(cnv); + }); + } + + void finishSuggestions(Canvas* cnv) + { + if (cnv) + cnv->hideSuggestions(); + + auto& tabbar = editor->getTabComponent(); + while (auto* c = tabbar.getCurrentCanvas()) + tabbar.closeTab(c); + + signalDone(true); + } +}; diff --git a/Tests/CoverageSweepTest.h b/Tests/CoverageSweepTest.h new file mode 100644 index 000000000..83fa27b74 --- /dev/null +++ b/Tests/CoverageSweepTest.h @@ -0,0 +1,2498 @@ +#include "Components/ConnectionMessageDisplay.h" +#include "Components/BouncingViewport.h" +#include "Components/ObjectDragAndDrop.h" +#include "Components/SuggestionComponent.h" +#include "Components/TouchPopupMenu.h" +#include "Components/TouchSelectionHelper.h" +#include "Components/WelcomePanel.h" +#include "Dialogs/Dialogs.h" +#include "Dialogs/AboutPanel.h" +#include "Dialogs/SettingsDialog.h" +#include "Dialogs/AudioSettingsPanel.h" +#include "Dialogs/KeyMappingSettingsPanel.h" +#include "Dialogs/ObjectBrowserDialog.h" +#include "Heavy/ExportingProgressView.h" +#include "Heavy/ExporterBase.h" +#include "Heavy/CppExporter.h" +#include "Heavy/DaisyExporter.h" +#include "Heavy/DPFExporter.h" +#include "Heavy/OWLExporter.h" +#include "Heavy/PdExporter.h" +#include "Heavy/ToolchainInstaller.h" +#include "Heavy/WASMExporter.h" +#include "PluginMode.h" +#include "Objects/TextObject.h" +extern "C" { +void canvas_setgraph(t_glist* x, int flag, int nogoprect); +} +#include "Objects/GraphOnParent.h" +#include "Objects/ArrayObject.h" +#include "Objects/BicoeffObject.h" +#include "Objects/ButtonObject.h" +#include "Objects/DropzoneObject.h" +#include "Objects/KeyboardObject.h" +#include "Objects/KnobObject.h" +#include "Sidebar/AutomationPanel.h" +#include "Sidebar/CommandInput.h" +#include "Sidebar/Palettes.h" +#include "Utility/ValueTreeViewer.h" +#include "Sidebar/SearchPanel.h" +#include "Statusbar.h" +#include "Toolbar.h" +#include "Utility/AudioMidiFifo.h" +#include "Utility/Autosave.h" +#include "Utility/CachedTextRender.h" +#include "Utility/ModifierKeyListener.h" +#include "Utility/Recorder.h" + +// One broad, deterministic sweep for code paths that are expensive to cover +// with isolated tests. It drives legal values through object properties and +// settings controls, traverses every sidebar, exercises application command +// states, and opens the otherwise dormant plugin-mode and utility UIs. +class CoverageSweepTest : public PlugDataUnitTest +{ +public: + CoverageSweepTest(PluginEditor* editor) : PlugDataUnitTest(editor, "Coverage Sweep Test") + { + } + +private: + void perform() override + { + beginTest("Object properties, commands and canvas tools"); + + cnv = editor->getTabComponent().openPatch(String( + "#N canvas 100 100 1100 760 12;\n" + "#X obj 20 20 bng 25 250 50 0 empty empty bang 17 7 0 10 #fcfcfc #000000 #000000;\n" + "#X obj 70 20 tgl 25 0 empty empty toggle 17 7 0 10 #fcfcfc #000000 #000000 0 1;\n" + "#X obj 120 20 hsl 128 17 0 127 0 0 empty empty hslider -2 -8 0 10 #fcfcfc #000000 #000000 0 1;\n" + "#X obj 270 20 vsl 17 128 0 127 0 0 empty empty vslider 0 -9 0 10 #fcfcfc #000000 #000000 0 1;\n" + "#X obj 320 20 nbx 5 14 -10 10 0 0 empty empty number 0 -8 0 10 #fcfcfc #000000 #000000 0 256;\n" + "#X obj 400 20 hradio 17 1 0 8 empty empty hradio 0 -8 0 10 #fcfcfc #000000 #000000 0;\n" + "#X obj 560 20 vradio 17 1 0 8 empty empty vradio 0 -8 0 10 #fcfcfc #000000 #000000 0;\n" + "#X obj 620 20 cnv 15 120 60 empty empty canvas 20 12 0 14 #e0e0e0 #404040 0;\n" + "#X obj 760 20 vu 17 120 empty empty -1 -8 0 10 #404040 #000000 1 0;\n" + "#X floatatom 20 190 5 0 0 0 float - - 0;\n" + "#X symbolatom 120 190 10 0 0 0 symbol - - 0;\n" + "#X listbox 280 190 20 0 0 0 list - - 0;\n" + "#X msg 20 240 message coverage;\n" + "#X text 180 240 comment coverage;\n" + "#X obj 20 300 knob;\n" + "#X obj 100 300 function;\n" + "#X obj 260 300 scope~;\n" + "#X obj 430 300 keyboard;\n" + "#X obj 650 300 note;\n" + "#X obj 20 430 button;\n" + "#X obj 100 430 pad;\n" + "#X obj 250 430 popmenu;\n" + "#X obj 400 430 messbox;\n" + "#X obj 560 430 bicoeff;\n" + "#X obj 720 430 numbox~;\n" + "#X obj 20 540 array define coverage_array 64;\n" + "#X obj 250 540 colors;\n" + "#X obj 400 540 openfile -h;\n" + "#X obj 560 540 notein;\n" + "#X obj 680 540 noteout;\n" + "#X obj 20 620 osc~ 220;\n" + "#X obj 180 620 *~ 0.25;\n" + "#X obj 350 620 print coverage;\n" + "#X obj 470 620 canvas.mouse;\n" + "#X obj 600 620 canvas.vis;\n" + "#X obj 720 620 canvas.zoom;\n" + "#X obj 840 620 canvas.edit;\n" + "#X obj 470 660 keycode;\n" + "#X obj 570 660 mouse;\n" + "#X obj 650 660 mousestate;\n" + "#X obj 770 660 mousefilter;\n" + "#X obj 900 660 pd~;\n" + "#X obj 970 660 clone 2 osc~ 440;\n" + "#X obj 800 540 hello-gui;\n" + "#X obj 800 580 props;\n" + "#X obj 920 100 receive coverage_bus;\n" + "#X obj 920 140 send coverage_bus;\n" + "#X obj 820 620 dropzone 160 80;\n" + "#N canvas 0 50 450 250 (subpatch) 0;\n" + "#X array coverage_graph 64 float 3;\n" + "#A 0 0 0.1 0.2 0.3 0.4 0.5 0.4 0.3 0.2 0.1 0 -0.1 -0.2 -0.3 -0.4 -0.5;\n" + "#X coords 0 1 63 -1 180 100 1;\n" + "#X restore 800 480 graph;\n" + "#X connect 30 0 31 0;\n" + "#X connect 12 0 32 0;\n" + "#X coords 0 -1 1 1 900 680 1 0 0;\n")); + + if (!cnv) { + signalDone(false); + return; + } + + cnv->locked.setValue(false); + cnv->performSynchronise(); + exerciseCommandsAndCanvas(); + exerciseObjectProperties(); + exerciseObjectMessages(); + exerciseTabsAndFileDrops(); + + Timer::callAfterDelay(100, [this] { runSidebar(0); }); + } + + static MouseEvent mouseEvent(Component* target, Point position, ModifierKeys modifiers = ModifierKeys::leftButtonModifier, int clicks = 1) + { + return MouseEvent(Desktop::getInstance().getMainMouseSource(), + position, modifiers, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, target, target, + Time::getCurrentTime(), position, Time::getCurrentTime(), clicks, false); + } + + static MouseEvent dragEvent(Component* target, Point position, Point mouseDownPosition, + ModifierKeys modifiers = ModifierKeys::leftButtonModifier) + { + return MouseEvent(Desktop::getInstance().getMainMouseSource(), + position, modifiers, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, target, target, + Time::getCurrentTime(), mouseDownPosition, Time::getCurrentTime(), 1, true); + } + + static void collectComponents(Component* root, HeapArray>& result) + { + if (!root) + return; + for (auto* child : root->getChildren()) { + result.add(child); + collectComponents(child, result); + } + } + + void exerciseControlTree(Component* root, bool mutateComboBoxes = true) + { + if (!root) + return; + + root->createComponentSnapshot(root->getLocalBounds()); + + HeapArray> components; + collectComponents(root, components); + for (auto& safe : components) { + auto* component = safe.getComponent(); + if (!component || !component->isVisible() || !component->isEnabled()) + continue; + + auto const originalBounds = component->getBounds(); + component->setBounds(originalBounds); + + auto const centre = component->getLocalBounds().getCentre().toFloat(); + auto move = mouseEvent(component, centre, ModifierKeys()); + component->mouseEnter(move); + component->mouseMove(move); + component->mouseExit(move); + + if (auto* toggle = dynamic_cast(component)) { + auto const original = toggle->getToggleState(); + toggle->setToggleState(!original, sendNotificationSync); + toggle->setToggleState(original, sendNotificationSync); + } else if (auto* combo = dynamic_cast(component); combo && mutateComboBoxes) { + auto const original = combo->getSelectedId(); + if (combo->getNumItems() > 0) { + combo->setSelectedId(combo->getItemId(0), sendNotificationSync); + combo->setSelectedId(combo->getItemId(combo->getNumItems() - 1), sendNotificationSync); + combo->setSelectedId(original, sendNotificationSync); + } + } else if (auto* slider = dynamic_cast(component)) { + auto const original = slider->getValue(); + auto const minimum = slider->getMinimum(); + auto const maximum = slider->getMaximum(); + if (std::isfinite(minimum) && std::isfinite(maximum) && maximum > minimum) { + slider->setValue(minimum, sendNotificationSync); + slider->setValue(maximum, sendNotificationSync); + slider->setValue(original, sendNotificationSync); + } + } + } + } + + void exerciseCommandsAndCanvas() + { + Array commands; + editor->getAllCommands(commands); + for (auto const command : commands) { + ApplicationCommandInfo info(command); + editor->getCommandInfo(command, info); + } + + cnv->deselectAll(); + for (int i = 0; i < jmin(5, static_cast(cnv->objects.size())); ++i) + cnv->setSelected(cnv->objects[i], true, false); + if (!cnv->connections.empty()) + cnv->setSelected(cnv->connections.front(), true, false); + + for (auto const command : commands) { + ApplicationCommandInfo info(command); + editor->getCommandInfo(command, info); + } + + for (int align = Align::Left; align <= Align::VDistribute; ++align) + cnv->alignObjects(static_cast(align)); + + for (auto const command : { + CommandIDs::ZoomIn, CommandIDs::ZoomOut, CommandIDs::ZoomNormal, + CommandIDs::ZoomToFitAll, CommandIDs::GoToOrigin, + CommandIDs::ConnectionStyle, CommandIDs::ConnectionPathfind, + CommandIDs::SelectAll, CommandIDs::ToggleSnapping, + CommandIDs::ToggleLeftSidebar, CommandIDs::ToggleRightSidebar, + CommandIDs::ShowBrowser, CommandIDs::Search, CommandIDs::ClearConsole, + CommandIDs::NextTab, CommandIDs::PreviousTab, CommandIDs::PanDragKey }) { + editor->commandManager.invokeDirectly(command, false); + } + editor->commandManager.invokeDirectly(CommandIDs::ToggleSnapping, false); + editor->commandManager.invokeDirectly(CommandIDs::ToggleLeftSidebar, false); + editor->commandManager.invokeDirectly(CommandIDs::ToggleRightSidebar, false); + + editor->commandManager.invokeDirectly(CommandIDs::Lock, false); + editor->commandManager.invokeDirectly(CommandIDs::Lock, false); + editor->commandManager.invokeDirectly(CommandIDs::TogglePresentationMode, false); + editor->commandManager.invokeDirectly(CommandIDs::TogglePresentationMode, false); + editor->commandManager.invokeDirectly(CommandIDs::ToggleDSP, false); + editor->commandManager.invokeDirectly(CommandIDs::ToggleDSP, false); + + cnv->shiftKeyChanged(true); + cnv->shiftKeyChanged(false); + cnv->commandKeyChanged(true); + cnv->commandKeyChanged(false); + cnv->middleMouseChanged(true); + cnv->middleMouseChanged(false); + cnv->altKeyChanged(true); + cnv->altKeyChanged(false); + cnv->cycleSelection(); + cnv->keyPressed(KeyPress(KeyPress::tabKey)); + cnv->keyPressed(KeyPress(KeyPress::escapeKey)); + + if (!cnv->objects.empty()) { + cnv->activateCanvasSearchHighlight({ 40.0f, 40.0f }, cnv->objects.front()); + cnv->removeCanvasSearchHighlight(); + } + + cnv->dragAndDropPaste( + "#N canvas 0 0 100 100 12;\n#X obj 20 20 osc~ 110;\n", + { 850, 620 }, 100, 100); + cnv->performSynchronise(); + exerciseObjectCreationCommands(); + } + + void exerciseObjectCreationCommands() + { + for (int command = ObjectIDs::NewComment; command < ObjectIDs::NumObjects; ++command) { + editor->commandManager.invokeDirectly(command, false); + cnv->performSynchronise(); + } + + cnv->deselectAll(); + for (int i = 0; i < jmin(3, static_cast(cnv->objects.size())); ++i) + cnv->setSelected(cnv->objects[i], true, false); + + for (auto const command : { + CommandIDs::Copy, CommandIDs::Duplicate, CommandIDs::Tidy, + CommandIDs::Triggerize, CommandIDs::Undo, CommandIDs::Redo }) { + editor->commandManager.invokeDirectly(command, false); + cnv->performSynchronise(); + } + } + + void exerciseDirectCommands() + { + auto invoke = [this](CommandID const command) { + ApplicationCommandTarget::InvocationInfo info(command); + editor->perform(info); + if (cnv) + cnv->performSynchronise(); + }; + + cnv->deselectAll(); + for (int i = 0; i < jmin(3, static_cast(cnv->objects.size())); ++i) + cnv->setSelected(cnv->objects[i], true, false); + if (!cnv->connections.empty()) + cnv->setSelected(cnv->connections.front(), true, false); + + for (auto const command : { + CommandIDs::Copy, CommandIDs::Duplicate, CommandIDs::Tidy, + CommandIDs::Triggerize, CommandIDs::SelectAll, + CommandIDs::ConnectionStyle, CommandIDs::ConnectionPathfind, + CommandIDs::ZoomIn, CommandIDs::ZoomOut, CommandIDs::ZoomNormal, + CommandIDs::ZoomToFitAll, CommandIDs::GoToOrigin, + CommandIDs::PanDragKey, CommandIDs::Undo, CommandIDs::Redo, + CommandIDs::NextTab, CommandIDs::PreviousTab }) + invoke(command); + + invoke(CommandIDs::Lock); + invoke(CommandIDs::Lock); + invoke(CommandIDs::TogglePresentationMode); + invoke(CommandIDs::TogglePresentationMode); + + cnv->deselectAll(); + if (!cnv->objects.empty()) + cnv->setSelected(cnv->objects.front(), true, false); + + invoke(CommandIDs::ShowReference); + editor->openedDialog.reset(nullptr); + invoke(CommandIDs::OpenObjectBrowser); + editor->openedDialog.reset(nullptr); + invoke(CommandIDs::ShowSettings); + invoke(CommandIDs::ShowSettings); + + for (auto const command : { + CommandIDs::ShowBrowser, CommandIDs::Search, CommandIDs::ClearConsole, + CommandIDs::ToggleLeftSidebar, CommandIDs::ToggleRightSidebar, + CommandIDs::ToggleSnapping }) + invoke(command); + + invoke(CommandIDs::ToggleLeftSidebar); + invoke(CommandIDs::ToggleRightSidebar); + invoke(CommandIDs::ToggleSnapping); + } + + static var alternateValue(ObjectParameter const& parameter) + { + auto const original = parameter.valuePtr->getValue(); + switch (parameter.type) { + case tBool: + return !static_cast(original); + case tInt: { + auto const value = static_cast(original); + auto const minimum = static_cast(parameter.min); + auto const maximum = static_cast(parameter.max); + return jlimit(minimum, maximum, value == maximum ? minimum : value + 1); + } + case tFloat: { + auto const value = static_cast(original); + auto const candidate = value + 0.5; + return parameter.clip ? jlimit(parameter.min, parameter.max, candidate) : candidate; + } + case tCombo: + return parameter.options.size() > 1 ? 1 : 0; + case tString: + return original.toString() + "_coverage"; + case tColour: + case tColourAlpha: + return Colour(0xff336699).toString(); + case tRangeFloat: + case tRangeInt: { + if (auto const* values = original.getArray(); values && values->size() >= 2) { + Array changed(*values); + changed.set(0, static_cast(changed[0]) + 1.0); + changed.set(1, static_cast(changed[1]) + 2.0); + return changed; + } + return Array { 1, 2 }; + } + case tFont: + return original; + case tCustom: + return {}; + } + return original; + } + + void exerciseObjectProperties() + { + HeapArray> objects; + for (auto* object : cnv->objects) + objects.add(object); + + for (auto& safe : objects) { + auto* object = safe.getComponent(); + if (!object || !object->gui) + continue; + + auto* gui = object->gui.get(); + auto const type = gui->getType(); + // The keyboard external rebroadcasts its size/property messages as + // note data, so generic mutation feeds its outlet back into its + // own inlet. It is exercised separately by the object fuzz tests. + if (type == "keyboard") + continue; + + gui->updateProperties(); + gui->getPdBounds(); + gui->getSelectableBounds(); + gui->setPdBounds(gui->getPdBounds()); + gui->lock(true); + gui->lock(false); + gui->canReceiveMouseEvent(1, 1); + gui->checkHvccCompatibility(); + + PopupMenu menu; + gui->getMenuOptions(menu); + + auto parameters = gui->getParameters().getParameters(); + for (auto& parameter : parameters) { + if (parameter.type == tCustom && parameter.createFn) { + std::unique_ptr custom(parameter.createFn()); + custom->setBounds(0, 0, 500, 240); + exerciseControlTree(custom.get()); + continue; + } + if (!parameter.valuePtr) + continue; + + auto const original = parameter.valuePtr->getValue(); + if (parameter.interactionFn) + parameter.interactionFn(true); + parameter.valuePtr->setValue(alternateValue(parameter)); + if (parameter.interactionFn) + parameter.interactionFn(false); + parameter.valuePtr->setValue(original); + } + + auto const centre = gui->getLocalBounds().getCentre().toFloat(); + auto event = mouseEvent(gui, centre); + gui->mouseEnter(event); + gui->mouseMove(event); + gui->mouseDown(event); + gui->mouseDrag(mouseEvent(gui, centre + Point(2, 2))); + gui->mouseUp(event); + gui->mouseDoubleClick(mouseEvent(gui, centre, ModifierKeys::leftButtonModifier, 2)); + gui->mouseExit(event); + gui->keyPressed(KeyPress(KeyPress::upKey)); + gui->keyPressed(KeyPress(KeyPress::downKey)); + gui->keyPressed(KeyPress(KeyPress::returnKey)); + gui->focusGained(Component::focusChangedDirectly); + gui->focusLost(Component::focusChangedDirectly); + } + + cnv->performSynchronise(); + } + + void exerciseObjectMessages() + { + beginTest("Object messages, graphical arrays and drop targets"); + + auto const number = SmallArray { pd::Atom(1.0f) }; + auto const pair = SmallArray { pd::Atom(2.0f), pd::Atom(5.0f) }; + auto const symbol = SmallArray { pd::Atom(gensym("coverage")) }; + + for (auto* object : cnv->objects) { + if (!object->gui) + continue; + + if (auto* knob = dynamic_cast(object->gui.get())) { + for (auto const selector : { + "float", "list", "set", "reload", "inc", "dec", "shift", + "angle", "offset", "arc", "start", "discrete", "circular", + "square", "readonly", "number", "numbersize", "active", + "jump", "arcstart", "exp", "log", "ticks", "transparent" }) + knob->receiveObjectMessage(hash(selector), number); + knob->receiveObjectMessage(hash("range"), pair); + knob->receiveObjectMessage(hash("numberpos"), pair); + knob->receiveObjectMessage(hash("send"), symbol); + knob->receiveObjectMessage(hash("receive"), symbol); + knob->receiveObjectMessage(hash("var"), symbol); + knob->receiveObjectMessage(hash("param"), symbol); + knob->receiveObjectMessage(hash("readonly"), { pd::Atom(0.0f) }); + for (auto const selector : { "fgcolor", "bgcolor", "colors", "arccolor", "init" }) + knob->receiveObjectMessage(hash(selector), {}); + + if (auto* control = TestHelpers::findChildOfType(knob)) { + auto const centre = control->getLocalBounds().getCentre().toFloat(); + control->mouseDown(mouseEvent(control, centre)); + control->mouseDrag(dragEvent(control, { centre.x + 12.0f, centre.y - 18.0f }, centre)); + control->mouseDrag(dragEvent(control, { 2.0f, 2.0f }, centre)); + control->mouseDrag(dragEvent(control, { control->getWidth() - 2.0f, 2.0f }, centre)); + control->mouseUp(mouseEvent(control, centre)); + MouseWheelDetails wheel; + wheel.deltaY = 0.25f; + control->grabKeyboardFocus(); + control->mouseWheelMove(mouseEvent(control, centre), wheel); + } + + knob->grabKeyboardFocus(); + for (auto const key : { + KeyPress(KeyPress::upKey), KeyPress(KeyPress::rightKey), + KeyPress(KeyPress::downKey), KeyPress(KeyPress::leftKey), + KeyPress('1'), KeyPress('.'), KeyPress('5'), + KeyPress(KeyPress::backspaceKey), KeyPress(KeyPress::returnKey) }) + knob->keyPressed(key); + knob->canReceiveMouseEvent(1, 1); + } + + if (auto* keyboard = dynamic_cast(object->gui.get())) { + keyboard->receiveObjectMessage(hash("float"), number); + keyboard->receiveObjectMessage(hash("list"), pair); + keyboard->receiveObjectMessage(hash("set"), {}); + keyboard->receiveObjectMessage(hash("on"), pair); + keyboard->receiveObjectMessage(hash("off"), pair); + for (auto const selector : { "lowc", "width", "oct", "8ves", "toggle" }) + keyboard->receiveObjectMessage(hash(selector), number); + keyboard->receiveObjectMessage(hash("send"), symbol); + keyboard->receiveObjectMessage(hash("receive"), symbol); + keyboard->receiveObjectMessage(hash("flush"), {}); + keyboard->createConstrainer(); + } + + if (auto* button = dynamic_cast(object->gui.get())) { + for (auto const selector : { + "bgcolor", "fgcolor", "readonly", "transparent", "oval", + "float", "click", "latch", "bang", "toggle" }) + button->receiveObjectMessage(hash(selector), number); + } + + if (auto* filter = dynamic_cast(object->gui.get())) { + for (auto const selector : { + "allpass", "lowpass", "highpass", "bandpass", "bandstop", + "resonant", "eq", "lowshelf", "highshelf" }) { + filter->receiveObjectMessage(hash(selector), {}); + filter->createComponentSnapshot(filter->getLocalBounds()); + } + } + + if (auto* dropzone = dynamic_cast(object->gui.get())) { + StringArray files { "/tmp/coverage-one.txt", "/tmp/coverage-two.txt" }; + dropzone->isLocked(); + dropzone->isInterestedInFileDrag(files); + dropzone->fileDragEnter(files, 2, 3); + dropzone->fileDragMove(files, 4, 5); + dropzone->fileDragExit(files); + dropzone->filesDropped(files, 6, 7); + dropzone->isInterestedInTextDrag("coverage text"); + dropzone->textDragEnter("coverage text", 2, 3); + dropzone->textDragMove("coverage text", 4, 5); + dropzone->textDragExit("coverage text"); + dropzone->textDropped("coverage text", 6, 7); + dropzone->createComponentSnapshot(dropzone->getLocalBounds()); + } + + if (auto* array = dynamic_cast(object->gui.get())) + exerciseGraphicalArray(array, object); + } + } + + void exerciseGraphicalArray(ArrayObject* array, Object* object) + { + auto const arrays = array->getArrays(); + SmallArray graphs; + for (auto* child : array->getChildren()) { + if (auto* graph = dynamic_cast(child)) + graphs.add(graph); + } + + array->canReceiveMouseEvent(1, 1); + array->getPdBounds(); + array->setPdBounds(array->getPdBounds()); + array->updateGraphs(); + array->update(); + array->updateSizeProperty(); + array->resized(); + array->receiveObjectMessage(hash("redraw"), {}); + for (auto const selector : { "yticks", "xticks", "ylabel", "xlabel" }) + array->receiveObjectMessage(hash(selector), {}); + + HeapArray empty; + GraphicalArray::rescale(empty, 4); + HeapArray points { 0.0f, 0.25f, -0.25f, 0.5f, -0.5f, 0.0f }; + GraphicalArray::rescale(points, 12); + for (auto const style : { GraphicalArray::Points, GraphicalArray::Polygon, GraphicalArray::Curve }) + GraphicalArray::createArrayPath(points, style, { -1.0f, 1.0f }, 3.0f, 80.0f, 1.0f); + + for (auto* graph : graphs) { + graph->setBounds(0, 0, 240, 120); + graph->update(); + graph->updateParameters(); + graph->getUnexpandedName(); + graph->getArraySize(); + graph->getLineWidth(); + graph->getDrawType(); + graph->getScale(); + graph->willSaveContent(); + + graph->receiveMessage(gensym("edit"), { pd::Atom(1.0f) }); + graph->receiveMessage(gensym("color"), {}); + graph->receiveMessage(gensym("width"), {}); + graph->receiveMessage(gensym("style"), { pd::Atom(0.0f) }); + graph->receiveMessage(gensym("style"), { pd::Atom(1.0f) }); + graph->receiveMessage(gensym("style"), { pd::Atom(2.0f) }); + graph->receiveMessage(gensym("xticks"), {}); + graph->receiveMessage(gensym("yticks"), {}); + graph->receiveMessage(gensym("xlabel"), {}); + graph->receiveMessage(gensym("ylabel"), {}); + graph->receiveMessage(gensym("vis"), { pd::Atom(0.0f) }); + graph->receiveMessage(gensym("vis"), { pd::Atom(1.0f) }); + graph->receiveMessage(gensym("resize"), { pd::Atom(80.0f) }); + + graph->range = var(VarArray { -2.0f, 2.0f }); + graph->drawMode = 1; + graph->drawMode = 2; + graph->drawMode = 3; + graph->saveContents = !static_cast(graph->saveContents.getValue()); + graph->saveContents = !static_cast(graph->saveContents.getValue()); + + auto down = mouseEvent(graph, { 10.0f, 30.0f }); + graph->mouseDown(down); + graph->mouseDrag(mouseEvent(graph, { 180.0f, 90.0f })); + graph->mouseUp(mouseEvent(graph, { 180.0f, 90.0f })); + graph->createComponentSnapshot(graph->getLocalBounds()); + } + + ArrayPropertiesPanel properties([array] { array->addArray(); }, [this] { cnv->synchronise(); }); + SmallArray> safeGraphs; + for (auto* graph : graphs) + safeGraphs.add(graph); + properties.setBounds(0, 0, 420, 500); + properties.reloadGraphs(safeGraphs); + properties.resized(); + properties.createComponentSnapshot(properties.getLocalBounds()); + properties.addButton.mouseEnter(mouseEvent(&properties.addButton, { 10.0f, 10.0f })); + properties.addButton.mouseExit(mouseEvent(&properties.addButton, { 10.0f, 10.0f })); + properties.addButton.hitTest(10, 10); + + if (arrays.not_empty()) { + ArrayListView list(editor->pd, arrays.front()); + list.setBounds(0, 0, 500, 320); + list.parentSizeChanged(); + list.update(); + exerciseControlTree(&list); + + ArrayEditorDialog dialog(editor->pd, arrays, object); + dialog.setBounds(0, 0, 620, 420); + dialog.resized(); + dialog.updateGraphs(); + dialog.updateVisibleGraph(); + exerciseControlTree(&dialog); + dialog.createComponentSnapshot(dialog.getLocalBounds()); + dialog.removeFromDesktop(); + } + } + + void exerciseTabsAndFileDrops() + { + beginTest("Tabs, split views and file drops"); + + auto& tabs = editor->getTabComponent(); + auto* second = tabs.openPatch("#N canvas 0 0 320 240 12;\n#X obj 20 20 print second;\n"); + auto* third = tabs.openPatch("#N canvas 0 0 320 240 12;\n#X obj 20 20 print third;\n"); + expect(second && third, "additional patches must open"); + + if (second) + second->patch.splitViewIndex = 1; + if (third) + third->patch.splitViewIndex = 0; + tabs.updateNow(); + tabs.setBounds(tabs.getBounds()); + tabs.showTab(cnv, 0); + if (second) + tabs.showTab(second, 1); + tabs.setActiveSplit(cnv); + tabs.nextTab(); + tabs.previousTab(); + tabs.getCanvases(); + tabs.getVisibleCanvases(); + tabs.getCanvasAtScreenPosition(tabs.localPointToGlobal(tabs.getLocalBounds().getCentre())); + tabs.createComponentSnapshot(tabs.getLocalBounds()); + + HeapArray> tabChildren; + collectComponents(&tabs, tabChildren); + for (auto& safe : tabChildren) { + auto* component = safe.getComponent(); + if (!component) + continue; + + if (auto* button = dynamic_cast(component); + button && component->getParentComponent() == &tabs) { + button->triggerClick(); + PopupMenu::dismissAllActiveMenus(); + } + + if (component->getWidth() < 60 || component->getHeight() > 40) + continue; + auto const centre = component->getLocalBounds().getCentre().toFloat(); + component->mouseDown(mouseEvent(component, centre)); + component->mouseDrag(mouseEvent(component, centre + Point(20.0f, 0.0f))); + component->mouseUp(mouseEvent(component, centre + Point(20.0f, 0.0f))); + component->mouseDown(mouseEvent(component, centre, ModifierKeys::rightButtonModifier)); + PopupMenu::dismissAllActiveMenus(); + } + + tabs.showTab(cnv, 0); + tabs.setActiveSplit(cnv); + + auto dropDir = File::getSpecialLocation(File::tempDirectory).getChildFile("plugdata_coverage_drop"); + dropDir.createDirectory(); + auto const patch = dropDir.getChildFile("dropped.pd"); + patch.replaceWithText("#N canvas 0 0 200 150 12;\n#X obj 20 20 print dropped;\n"); + auto const text = dropDir.getChildFile("dropped.txt"); + text.replaceWithText("coverage"); + + StringArray patchFiles { patch.getFullPathName() }; + StringArray textFiles { text.getFullPathName() }; + editor->isInterestedInFileDrag(patchFiles); + editor->isInterestedInFileDrag(textFiles); + editor->isInterestedInFileDrag({ "/not/a/file" }); + editor->fileDragEnter(patchFiles, editor->getWidth() / 2, editor->getHeight() / 2); + editor->fileDragMove(patchFiles, editor->getWidth() / 2, editor->getHeight() / 2); + editor->fileDragExit(patchFiles); + editor->filesDropped(textFiles, editor->getWidth() / 2, editor->getHeight() / 2); + editor->filesDropped(patchFiles, editor->getWidth() / 2, editor->getHeight() / 2); + tabs.updateNow(); + tabs.showTab(cnv, 0); + tabs.setActiveSplit(cnv); + + exerciseArrayFileDrop(dropDir); + } + + void exerciseArrayFileDrop(File const& directory) + { + auto const wav = directory.getChildFile("coverage.wav"); + { + auto stream = std::unique_ptr(wav.createOutputStream()); + WavAudioFormat format; + AudioFormatWriterOptions options; + options = options.withSampleRate(44100.0).withNumChannels(1).withBitsPerSample(16); + auto writer = format.createWriterFor(stream, options); + if (writer) { + AudioBuffer audio(1, 128); + for (int i = 0; i < audio.getNumSamples(); ++i) + audio.setSample(0, i, std::sin(MathConstants::twoPi * i / 32.0f) * 0.25f); + writer->writeFromAudioSampleBuffer(audio, 0, audio.getNumSamples()); + } + } + + for (auto* object : cnv->objects) { + if (!object->gui) + continue; + + HeapArray> components; + components.add(object->gui.get()); + collectComponents(object->gui.get(), components); + for (auto& safe : components) { + auto* graph = dynamic_cast(safe.getComponent()); + if (!graph) + continue; + StringArray files { wav.getFullPathName() }; + graph->isInterestedInFileDrag(files); + graph->fileDragEnter(files, 2, 2); + graph->fileDragExit(files); + graph->filesDropped(files, 5, 5); + } + } + cnv->performSynchronise(); + } + + void runSidebar(int panel) + { + if (panel >= Sidebar::NumSidePanels) { + runSettingsPanel(0); + return; + } + + beginTest("Sidebar panel " + String(panel)); + auto const id = static_cast(panel); + editor->movePanelToSide(id, Sidebar::Side::Left); + editor->movePanelToSide(id, Sidebar::Side::Right); + if (auto* sidebar = editor->getSidebarForPanel(id)) { + sidebar->showSidebar(true); + sidebar->showPanel(id); + sidebar->updateConsole(3, true); + sidebar->updateSearch(false); + sidebar->updateAutomationParameters(); + sidebar->setCommandTarget("coverage"); + exerciseControlTree(sidebar); + sidebar->showSidebar(false); + sidebar->showSidebar(true); + } + + Timer::callAfterDelay(40, [this, panel] { runSidebar(panel + 1); }); + } + + void runSettingsPanel(int panel) + { + if (panel >= 6) { + exerciseTargetedComponents(); + return; + } + + beginTest("Interactive settings panel " + String(panel)); + Dialogs::showSettingsDialog(editor, panel); + Timer::callAfterDelay(100, [this, panel] { + exerciseControlTree(editor->openedDialog.get(), false); + editor->openedDialog.reset(nullptr); + Timer::callAfterDelay(30, [this, panel] { runSettingsPanel(panel + 1); }); + }); + } + + void exerciseTargetedComponents() + { + beginTest("Search, palettes, automation and settings callbacks"); + + SearchPanel search(editor); + search.setBounds(0, 0, 520, 420); + search.setVisible(true); + auto tree = search.generatePatchTree(cnv->refCountedPatch); + expect(tree.getNumChildren() > 0, "search tree must contain the sweep objects"); + search.updateResults(); + search.timerCallback(); + search.grabFocus(); + search.lookAndFeelChanged(); + search.createComponentSnapshot(search.getLocalBounds()); + auto searchSettings = search.getExtraSettingsComponent(); + searchSettings->setBounds(0, 0, 30, 30); + exerciseControlTree(searchSettings.get()); + search.clear(); + + Palettes palettes(editor); + palettes.setBounds(0, 0, 420, 500); + palettes.updateSearch("osc"); + palettes.updateSearch("not-present"); + palettes.updateSearch(""); + Array paletteState; + palettes.initialisePalettes(paletteState); + palettes.populateValueTree(paletteState); + palettes.updateSearch("slider"); + palettes.createComponentSnapshot(palettes.getLocalBounds()); + exercisePalettes(palettes); + + themeSettings = std::make_unique(editor->pd); + auto& themes = *themeSettings; + themes.setBounds(0, 0, 760, 560); + themes.updateSwatches(); + themes.handleAsyncUpdate(); + themes.updateThemeNames("light", "dark"); + themes.resized(); + exerciseControlTree(themes.getPropertiesPanel()); + HeapArray> themeControls; + collectComponents(themes.getPropertiesPanel(), themeControls); + for (auto& safe : themeControls) { + if (auto* boolean = dynamic_cast(safe.getComponent())) { + auto const centre = boolean->getLocalBounds().getCentre().toFloat(); + boolean->mouseUp(mouseEvent(boolean, centre)); + boolean->mouseUp(mouseEvent(boolean, centre)); + } + } + themes.resetDefaults(); + themes.createComponentSnapshot(themes.getLocalBounds()); + + int newThemeResult = -1; + NewThemeDialog newTheme(nullptr, [&newThemeResult](int result, String, String) { + newThemeResult = result; + }); + newTheme.setBounds(0, 0, 400, 170); + newTheme.resized(); + if (auto* ok = TestHelpers::findButtonWithText(&newTheme, "OK")) { + ok->triggerClick(); + newTheme.createComponentSnapshot(newTheme.getLocalBounds()); + if (auto* name = TestHelpers::findChildOfType(&newTheme)) { + name->setText(PlugDataLook::getAllThemes()[0], sendNotificationSync); + ok->triggerClick(); + newTheme.createComponentSnapshot(newTheme.getLocalBounds()); + } + } + ignoreUnused(newThemeResult); + + int themeSelectorChanges = 0; + ThemeSelectorProperty selector("Coverage theme", [&themeSelectorChanges](String const&) { + ++themeSelectorChanges; + }); + selector.setOptions(PlugDataLook::getAllThemes()); + selector.setBounds(0, 0, 300, 30); + selector.resized(); + selector.setSelectedItem(0); + if (selector.comboBox.getNumItems() > 1) + selector.comboBox.setSelectedItemIndex(1, sendNotificationSync); + std::unique_ptr selectorCopy(selector.createCopy()); + selector.createComponentSnapshot(selector.getLocalBounds()); + ignoreUnused(themeSelectorChanges); + + SearchPathPanel paths; + paths.setBounds(0, 0, 760, 520); + paths.onChange = [this] { ++settingsCallbackCount; }; + auto const tempDirectory = File::getSpecialLocation(File::tempDirectory); + paths.isInterestedInFileDrag({ tempDirectory.getFullPathName() }); + paths.filesDropped({ tempDirectory.getFullPathName(), "/not/a/directory" }, 10, 10); + paths.selectedRowsChanged(0); + paths.deleteKeyPressed(10000); + paths.createComponentSnapshot(paths.getLocalBounds()); + exercisePathEditors(); + exerciseCoverageGaps(); + + if (SystemStats::getEnvironmentVariable("PLUGDATA_COVERAGE_GAPS_ONLY", {}) == "1") { + signalDone(true); + return; + } + + exerciseSuggestions(); + exerciseWelcomePanel(); + exerciseDialogsAndMenus(); + exerciseExporters(); + exerciseUtilitiesAndChrome(); + exerciseAutomation(); + exerciseProcessor(); + exerciseDirectCommands(); + + Timer::callAfterDelay(100, [this] { exerciseValueTree(); }); + } + + void exercisePalettes(Palettes& palettes) + { + HeapArray> paletteControls; + collectComponents(&palettes, paletteControls); + for (auto& safe : paletteControls) { + auto* selector = dynamic_cast(safe.getComponent()); + if (!selector) + continue; + + auto const originalName = selector->getButtonText(); + selector->getTree(); + selector->setTextToShow("Coverage Palette"); + selector->animateToPosition(selector->getBounds().translated(4, 0)); + selector->getTargetBounds(); + selector->cancelAnimation(selector->getBounds()); + + if (auto* label = TestHelpers::findChildOfType