|
| 1 | +//-- |
| 2 | +// This file is part of Sonic Pi: http://sonic-pi.net |
| 3 | +// Full project source: https://github.com/sonic-pi-net/sonic-pi |
| 4 | +// License: https://github.com/sonic-pi-net/sonic-pi/blob/main/LICENSE.md |
| 5 | +// |
| 6 | +// Copyright 2026 by Sam Aaron (http://sam.aaron.name). |
| 7 | +// All rights reserved. |
| 8 | +// |
| 9 | +// Permission is granted for use, copying, modification, and |
| 10 | +// distribution of modified versions of this work as long as this |
| 11 | +// notice is included. |
| 12 | +//++ |
| 13 | + |
| 14 | +// A deck: the runs a host's code cards start, as the web's deck.js keeps |
| 15 | +// them. Each card's Play and Stop are the deck's; the session's run events |
| 16 | +// come back to the card whose run they are. |
| 17 | + |
| 18 | +#include <catch2/catch_test_macros.hpp> |
| 19 | + |
| 20 | +#include <QAbstractButton> |
| 21 | +#include <QApplication> |
| 22 | +#include <QLabel> |
| 23 | +#include <QPushButton> |
| 24 | +#include <QSignalSpy> |
| 25 | +#include <QTest> |
| 26 | +#include <QVBoxLayout> |
| 27 | + |
| 28 | +#include <memory> |
| 29 | + |
| 30 | +#include "model/sonicpitheme.h" |
| 31 | +#include "widgets/carddeck.h" |
| 32 | +#include "widgets/codecard.h" |
| 33 | +#include "widgets/tutorialwidgets.h" |
| 34 | + |
| 35 | +namespace { |
| 36 | + |
| 37 | +struct Host |
| 38 | +{ |
| 39 | + SonicPiTheme* theme = new SonicPiTheme(nullptr, "", QStringLiteral(SP_ROOT)); |
| 40 | + QWidget page; |
| 41 | + QVBoxLayout* column = new QVBoxLayout(&page); |
| 42 | + |
| 43 | + Host() { page.resize(800, 900); } |
| 44 | + ~Host() { delete theme; } |
| 45 | + |
| 46 | + CodeCard* card(const QString& title, const QString& code, unsigned int scopeSlot = 1) |
| 47 | + { |
| 48 | + CodeCard::Spec spec; |
| 49 | + spec.title = title; |
| 50 | + spec.code = code; |
| 51 | + spec.scopeSlot = scopeSlot; |
| 52 | + CodeCard::Metrics m; |
| 53 | + m.codeFontPx = 14; |
| 54 | + m.titlePx = 16; |
| 55 | + CodeCard* c = new CodeCard(spec, m, theme, &page); |
| 56 | + column->addWidget(c); |
| 57 | + return c; |
| 58 | + } |
| 59 | +}; |
| 60 | + |
| 61 | +void play(CodeCard* c) { c->playButton()->click(); } |
| 62 | +void stop(CodeCard* c) { c->stopButton()->click(); } |
| 63 | + |
| 64 | +QSet<int> stopped(const QSignalSpy& spy) |
| 65 | +{ |
| 66 | + QSet<int> jobs; |
| 67 | + for (const QList<QVariant>& call : spy) |
| 68 | + jobs.insert(call[0].toInt()); |
| 69 | + return jobs; |
| 70 | +} |
| 71 | + |
| 72 | +QString shown(QWidget* card, const char* name) |
| 73 | +{ |
| 74 | + QLabel* l = card->findChild<QLabel*>(QString::fromLatin1(name)); |
| 75 | + return l && !l->isHidden() ? l->text() : QString(); |
| 76 | +} |
| 77 | + |
| 78 | +} // namespace |
| 79 | + |
| 80 | +TEST_CASE("a deck runs a card's code and stops its runs", "[carddeck]") |
| 81 | +{ |
| 82 | + Host host; |
| 83 | + CardDeck deck(&host.page, CardDeck::Playing::Layered); |
| 84 | + CodeCard* a = host.card("A", "play 60", 3); |
| 85 | + deck.add(a, "ws-a"); |
| 86 | + QSignalSpy runs(&deck, &CardDeck::runRequested); |
| 87 | + QSignalSpy stops(&deck, &CardDeck::stopJobRequested); |
| 88 | + |
| 89 | + play(a); |
| 90 | + REQUIRE(runs.count() == 1); |
| 91 | + CHECK(runs[0][0].toString() == "A"); |
| 92 | + CHECK(runs[0][1].toString() == "play 60"); |
| 93 | + CHECK(runs[0][2].toString() == "ws-a"); |
| 94 | + CHECK(runs[0][3].toInt() == 3); |
| 95 | + CHECK_FALSE(a->isPlaying()); |
| 96 | + |
| 97 | + deck.runStarted(7, "ws-a"); |
| 98 | + CHECK(a->isPlaying()); |
| 99 | + deck.runStarted(99, "somebody-else"); // not the deck's |
| 100 | + stop(a); |
| 101 | + CHECK(stopped(stops) == QSet<int>{ 7 }); |
| 102 | + deck.runEnded(7); |
| 103 | + CHECK_FALSE(a->isPlaying()); |
| 104 | +} |
| 105 | + |
| 106 | +TEST_CASE("a layered deck plays its cards together", "[carddeck]") |
| 107 | +{ |
| 108 | + Host host; |
| 109 | + CardDeck deck(&host.page, CardDeck::Playing::Layered); |
| 110 | + CodeCard* drums = host.card("Drums", "live_loop :drums do\n sample :bd_haus\n sleep 1\nend"); |
| 111 | + CodeCard* bass = host.card("Bass", "live_loop :bass do\n play :e1\n sleep 1\nend"); |
| 112 | + deck.add(drums, "ws-d"); |
| 113 | + deck.add(bass, "ws-b"); |
| 114 | + QSignalSpy stops(&deck, &CardDeck::stopJobRequested); |
| 115 | + |
| 116 | + play(drums); |
| 117 | + deck.runStarted(7, "ws-d"); |
| 118 | + play(bass); |
| 119 | + deck.runStarted(8, "ws-b"); |
| 120 | + CHECK(drums->isPlaying()); |
| 121 | + CHECK(bass->isPlaying()); |
| 122 | + CHECK(stops.isEmpty()); |
| 123 | + |
| 124 | + stop(bass); |
| 125 | + CHECK(stopped(stops) == QSet<int>{ 8 }); |
| 126 | +} |
| 127 | + |
| 128 | +TEST_CASE("a card that takes over another's live_loops takes its runs too", "[carddeck]") |
| 129 | +{ |
| 130 | + Host host; |
| 131 | + CardDeck deck(&host.page, CardDeck::Playing::Layered); |
| 132 | + CodeCard* first = host.card("Beat", "live_loop :beat do\n sample :bd_haus\n sleep 1\nend"); |
| 133 | + CodeCard* second = host.card("Beat, faster", "live_loop :beat do\n sample :bd_haus\n sleep 0.5\nend"); |
| 134 | + deck.add(first, "ws-1"); |
| 135 | + deck.add(second, "ws-2"); |
| 136 | + QSignalSpy stops(&deck, &CardDeck::stopJobRequested); |
| 137 | + |
| 138 | + play(first); |
| 139 | + deck.runStarted(7, "ws-1"); |
| 140 | + play(second); |
| 141 | + deck.runStarted(8, "ws-2"); |
| 142 | + // live_loop redefines the running thread, which stays the first run's: |
| 143 | + // the second card plays it now, so its Stop is the one that ends it. |
| 144 | + CHECK_FALSE(first->isPlaying()); |
| 145 | + CHECK(second->isPlaying()); |
| 146 | + deck.runEnded(8); // the redefinition lands and its own run ends |
| 147 | + CHECK(second->isPlaying()); |
| 148 | + stop(second); |
| 149 | + CHECK(stopped(stops) == QSet<int>{ 7 }); |
| 150 | +} |
| 151 | + |
| 152 | +TEST_CASE("a deck that plays one card at a time stops the last for the next", "[carddeck]") |
| 153 | +{ |
| 154 | + Host host; |
| 155 | + CardDeck deck(&host.page, CardDeck::Playing::OneAtATime); |
| 156 | + CodeCard* a = host.card("A", "live_loop :a do\n play 60\n sleep 1\nend"); |
| 157 | + CodeCard* b = host.card("B", "live_loop :b do\n play 64\n sleep 1\nend"); |
| 158 | + deck.add(a, "ws-a"); |
| 159 | + deck.add(b, "ws-b"); |
| 160 | + QSignalSpy stops(&deck, &CardDeck::stopJobRequested); |
| 161 | + |
| 162 | + play(a); |
| 163 | + deck.runStarted(7, "ws-a"); |
| 164 | + play(b); |
| 165 | + CHECK(stopped(stops) == QSet<int>{ 7 }); |
| 166 | + CHECK_FALSE(a->isPlaying()); |
| 167 | + |
| 168 | + // Play again on the card that plays is a run again, not a stop. |
| 169 | + deck.runStarted(8, "ws-b"); |
| 170 | + stops.clear(); |
| 171 | + play(b); |
| 172 | + CHECK(stops.isEmpty()); |
| 173 | +} |
| 174 | + |
| 175 | +TEST_CASE("a card made again while its runs play is still playing", "[carddeck]") |
| 176 | +{ |
| 177 | + Host host; |
| 178 | + CardDeck deck(&host.page, CardDeck::Playing::Layered); |
| 179 | + CodeCard* a = host.card("A", "play 60"); |
| 180 | + deck.add(a, "ws-a"); |
| 181 | + play(a); |
| 182 | + deck.runStarted(7, "ws-a"); |
| 183 | + |
| 184 | + // A rebuild (a zoom, a theme) makes the cards again. |
| 185 | + deck.clear(); |
| 186 | + delete a; |
| 187 | + CodeCard* again = host.card("A", "play 60"); |
| 188 | + deck.add(again, "ws-a"); |
| 189 | + CHECK(again->isPlaying()); |
| 190 | + QSignalSpy stops(&deck, &CardDeck::stopJobRequested); |
| 191 | + stop(again); |
| 192 | + CHECK(stopped(stops) == QSet<int>{ 7 }); |
| 193 | +} |
| 194 | + |
| 195 | +TEST_CASE("a run that starts after its card went is still the deck's", "[carddeck]") |
| 196 | +{ |
| 197 | + Host host; |
| 198 | + CardDeck deck(&host.page, CardDeck::Playing::Layered); |
| 199 | + CodeCard* a = host.card("A", "play 60"); |
| 200 | + deck.add(a, "ws-a"); |
| 201 | + play(a); |
| 202 | + // The host moves on (another deck) before the engine starts the run. |
| 203 | + deck.clear(); |
| 204 | + delete a; |
| 205 | + deck.runStarted(7, "ws-a"); |
| 206 | + // Back again: the card made again plays it, and stops it. |
| 207 | + CodeCard* again = host.card("A", "play 60"); |
| 208 | + deck.add(again, "ws-a"); |
| 209 | + CHECK(again->isPlaying()); |
| 210 | + QSignalSpy stops(&deck, &CardDeck::stopJobRequested); |
| 211 | + stop(again); |
| 212 | + CHECK(stopped(stops) == QSet<int>{ 7 }); |
| 213 | +} |
| 214 | + |
| 215 | +TEST_CASE("a deck shows a run's output, error and sounding line on its card", "[carddeck]") |
| 216 | +{ |
| 217 | + Host host; |
| 218 | + host.page.show(); |
| 219 | + CardDeck deck(&host.page, CardDeck::Playing::Layered); |
| 220 | + CodeCard* a = host.card("A", "puts :hi\nplay 60"); |
| 221 | + CodeCard* b = host.card("B", "play 72"); |
| 222 | + deck.add(a, "ws-a"); |
| 223 | + deck.add(b, "ws-b"); |
| 224 | + play(a); |
| 225 | + deck.runStarted(7, "ws-a"); |
| 226 | + |
| 227 | + deck.runOutput(7, "hi"); |
| 228 | + deck.runOutput(99, "not the deck's"); |
| 229 | + CHECK(shown(a, "qsCardOutput").contains("hi")); |
| 230 | + CHECK(shown(b, "qsCardOutput").isEmpty()); |
| 231 | + |
| 232 | + // A run's lines count the with_fx :scope_out the code is wrapped in. |
| 233 | + deck.flashLine("ws-a", 3); |
| 234 | + CHECK(a->codeText()->washedLine() == 1); |
| 235 | + deck.runError(7, "Unknown function `plya`", 3); |
| 236 | + CHECK(shown(a, "qsCardError") == "Unknown function `plya` (line 2)"); |
| 237 | +} |
0 commit comments