From 4f6b47acfd09463ca59576d14192d865e6b992bd Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 19 Apr 2026 17:32:15 -0400 Subject: [PATCH 1/2] fix dirty tracking, backoff overflow, & edge cases --- src/core/Show.cpp | 28 +++++++++++-------------- src/core/Show.h | 2 +- src/core/UndoCommands.cpp | 11 +++++++--- src/protocol/transport/TcpTransport.cpp | 4 +++- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 4068ac4..bae359b 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -34,25 +34,21 @@ void Show::setName(const QString& name) { } } -bool Show::isModified() const { return toJson() != m_originalState; } +bool Show::isModified() const { return m_isDirty; } void Show::setModified(bool modified) { - if (!modified) { - m_originalState = toJson(); - if (m_lastEmittedModified) { - m_lastEmittedModified = false; - emit modifiedChanged(false); - } - } else { - checkModifiedState(); - } + if (m_isDirty == modified) + return; + m_isDirty = modified; + m_lastEmittedModified = modified; + emit modifiedChanged(modified); } void Show::checkModifiedState() { - bool nowModified = isModified(); - if (nowModified != m_lastEmittedModified) { - m_lastEmittedModified = nowModified; - emit modifiedChanged(nowModified); + if (!m_isDirty) { + m_isDirty = true; + m_lastEmittedModified = true; + emit modifiedChanged(true); } } @@ -79,7 +75,7 @@ void Show::newShow() { m_mixerConfig.port = 10023; m_cueList.clear(); m_dcaMapping.clear(); - m_originalState = toJson(); + m_isDirty = false; m_lastEmittedModified = false; } @@ -108,7 +104,7 @@ void Show::fromJson(const QJsonObject& json) { m_dcaMapping.clear(); } - m_originalState = toJson(); + m_isDirty = false; m_lastEmittedModified = false; emit nameChanged(m_name); } diff --git a/src/core/Show.h b/src/core/Show.h index 049b45f..396ecd6 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -72,7 +72,7 @@ class Show : public QObject { CueList m_cueList; MixerConfig m_mixerConfig; DCAMapping m_dcaMapping; - QJsonObject m_originalState; + bool m_isDirty = false; bool m_lastEmittedModified = false; }; diff --git a/src/core/UndoCommands.cpp b/src/core/UndoCommands.cpp index e8c081a..c711b6e 100644 --- a/src/core/UndoCommands.cpp +++ b/src/core/UndoCommands.cpp @@ -1,5 +1,6 @@ #include "UndoCommands.h" #include "CueList.h" +#include namespace OpenMix { @@ -51,7 +52,8 @@ AddCueCommand::AddCueCommand(CueList* cueList, const Cue& cue, int index, QUndoC void AddCueCommand::undo() { if (m_cueList) { int idx = m_index >= 0 ? m_index : m_cueList->count() - 1; - m_cueList->removeCue(idx); + if (idx >= 0) + m_cueList->removeCue(idx); } } @@ -129,6 +131,7 @@ BatchEditCommand::BatchEditCommand(CueList* cueList, const QVector& indices const QString& text, QUndoCommand* parent) : QUndoCommand(parent), m_cueList(cueList), m_indices(indices), m_oldCues(oldCues), m_newCues(newCues) { + Q_ASSERT(indices.size() == oldCues.size() && oldCues.size() == newCues.size()); setText(text); } @@ -136,7 +139,8 @@ void BatchEditCommand::undo() { if (!m_cueList) return; - for (int i = 0; i < m_indices.size(); ++i) { + int count = std::min({m_indices.size(), m_oldCues.size(), m_newCues.size()}); + for (int i = 0; i < count; ++i) { int idx = m_indices[i]; if (idx >= 0 && idx < m_cueList->count()) { m_cueList->updateCue(idx, m_oldCues[i]); @@ -153,7 +157,8 @@ void BatchEditCommand::redo() { if (!m_cueList) return; - for (int i = 0; i < m_indices.size(); ++i) { + int count = std::min({m_indices.size(), m_oldCues.size(), m_newCues.size()}); + for (int i = 0; i < count; ++i) { int idx = m_indices[i]; if (idx >= 0 && idx < m_cueList->count()) { m_cueList->updateCue(idx, m_newCues[i]); diff --git a/src/protocol/transport/TcpTransport.cpp b/src/protocol/transport/TcpTransport.cpp index 6ec7c05..4ced4d8 100644 --- a/src/protocol/transport/TcpTransport.cpp +++ b/src/protocol/transport/TcpTransport.cpp @@ -1,4 +1,5 @@ #include "TcpTransport.h" +#include namespace OpenMix { @@ -147,7 +148,8 @@ void TcpTransport::startReconnection() { return; // exponential backoff - int delay = m_reconnectDelayMs * (1 << m_reconnectAttempts); + int shift = std::min(m_reconnectAttempts, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); } From 1ee332b0528a9df3a88a2e6e3041bc3935294d35 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 19 Apr 2026 22:34:02 -0400 Subject: [PATCH 2/2] fix type-punning UB, Yamaha blob guard, dirty tracking gaps, & add regression tests --- src/core/Show.cpp | 4 - src/core/Show.h | 7 +- src/core/UndoCommands.cpp | 7 +- .../allenheath/AllenHeathTcpProtocol.cpp | 37 +++--- src/protocol/behringer/WingProtocol.cpp | 9 +- src/protocol/behringer/X32Protocol.cpp | 9 +- src/protocol/transport/OscTransport.cpp | 2 + src/protocol/yamaha/YamahaProtocol.cpp | 11 +- src/protocol/yamaha/YamahaProtocol.h | 4 +- tests/CMakeLists.txt | 86 ++++++++++++- tests/test_allenheath_parsing.cpp | 103 +++++++++++++++ tests/test_show.cpp | 119 ++++++++++++++++++ tests/test_undo_commands.cpp | 64 ++++++++++ tests/test_yamaha_osc.cpp | 80 ++++++++++++ 14 files changed, 497 insertions(+), 45 deletions(-) create mode 100644 tests/test_allenheath_parsing.cpp create mode 100644 tests/test_show.cpp create mode 100644 tests/test_undo_commands.cpp create mode 100644 tests/test_yamaha_osc.cpp diff --git a/src/core/Show.cpp b/src/core/Show.cpp index bae359b..3597780 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -40,14 +40,12 @@ void Show::setModified(bool modified) { if (m_isDirty == modified) return; m_isDirty = modified; - m_lastEmittedModified = modified; emit modifiedChanged(modified); } void Show::checkModifiedState() { if (!m_isDirty) { m_isDirty = true; - m_lastEmittedModified = true; emit modifiedChanged(true); } } @@ -76,7 +74,6 @@ void Show::newShow() { m_cueList.clear(); m_dcaMapping.clear(); m_isDirty = false; - m_lastEmittedModified = false; } QJsonObject Show::toJson() const { @@ -105,7 +102,6 @@ void Show::fromJson(const QJsonObject& json) { } m_isDirty = false; - m_lastEmittedModified = false; emit nameChanged(m_name); } diff --git a/src/core/Show.h b/src/core/Show.h index 396ecd6..7180e26 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -31,10 +31,10 @@ class Show : public QObject { void setName(const QString& name); [[nodiscard]] QString author() const { return m_author; } - void setAuthor(const QString& author) { m_author = author; } + void setAuthor(const QString& author) { m_author = author; checkModifiedState(); } [[nodiscard]] QString notes() const { return m_notes; } - void setNotes(const QString& notes) { m_notes = notes; } + void setNotes(const QString& notes) { m_notes = notes; checkModifiedState(); } [[nodiscard]] QString filePath() const { return m_filePath; } void setFilePath(const QString& path) { m_filePath = path; } @@ -50,7 +50,7 @@ class Show : public QObject { [[nodiscard]] const DCAMapping* dcaMapping() const { return &m_dcaMapping; } [[nodiscard]] MixerConfig mixerConfig() const { return m_mixerConfig; } - void setMixerConfig(const MixerConfig& config) { m_mixerConfig = config; } + void setMixerConfig(const MixerConfig& config) { m_mixerConfig = config; checkModifiedState(); } QJsonObject toJson() const; void fromJson(const QJsonObject& json); @@ -73,7 +73,6 @@ class Show : public QObject { MixerConfig m_mixerConfig; DCAMapping m_dcaMapping; bool m_isDirty = false; - bool m_lastEmittedModified = false; }; } // namespace OpenMix diff --git a/src/core/UndoCommands.cpp b/src/core/UndoCommands.cpp index c711b6e..0411d8a 100644 --- a/src/core/UndoCommands.cpp +++ b/src/core/UndoCommands.cpp @@ -1,6 +1,5 @@ #include "UndoCommands.h" #include "CueList.h" -#include namespace OpenMix { @@ -139,8 +138,7 @@ void BatchEditCommand::undo() { if (!m_cueList) return; - int count = std::min({m_indices.size(), m_oldCues.size(), m_newCues.size()}); - for (int i = 0; i < count; ++i) { + for (int i = 0; i < m_indices.size(); ++i) { int idx = m_indices[i]; if (idx >= 0 && idx < m_cueList->count()) { m_cueList->updateCue(idx, m_oldCues[i]); @@ -157,8 +155,7 @@ void BatchEditCommand::redo() { if (!m_cueList) return; - int count = std::min({m_indices.size(), m_oldCues.size(), m_newCues.size()}); - for (int i = 0; i < count; ++i) { + for (int i = 0; i < m_indices.size(); ++i) { int idx = m_indices[i]; if (idx >= 0 && idx < m_cueList->count()) { m_cueList->updateCue(idx, m_newCues[i]); diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp index 87d3896..e96bb38 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp @@ -1,6 +1,7 @@ #include "AllenHeathTcpProtocol.h" #include "../../core/Cue.h" #include +#include namespace OpenMix { @@ -143,12 +144,10 @@ QByteArray AllenHeathTcpProtocol::buildDCAFaderMessage(int dca, float level) { msg.append(static_cast(dca - 1)); // 0-indexed // float in big-endian - union { - float f; - quint32 i; - } val; - val.f = qBound(0.0f, level, 1.0f); - quint32 be = qToBigEndian(val.i); + float clamped = qBound(0.0f, level, 1.0f); + quint32 i; + std::memcpy(&i, &clamped, 4); + quint32 be = qToBigEndian(i); msg.append(reinterpret_cast(&be), 4); return msg; @@ -231,13 +230,11 @@ void AllenHeathTcpProtocol::parseProtocolData(const QByteArray& data) { if (valueType == 0x01 && frame.size() >= valueOffset + 4) { // float value quint32 be; - memcpy(&be, frame.constData() + valueOffset, 4); - union { - float f; - quint32 i; - } fval; - fval.i = qFromBigEndian(be); - value = fval.f; + std::memcpy(&be, frame.constData() + valueOffset, 4); + quint32 hostBits = qFromBigEndian(be); + float f; + std::memcpy(&f, &hostBits, 4); + value = f; } else if (valueType == 0x02 && frame.size() >= valueOffset + 4) { quint32 be; memcpy(&be, frame.constData() + valueOffset, 4); @@ -275,15 +272,13 @@ void AllenHeathTcpProtocol::parseProtocolData(const QByteArray& data) { emit parameterChanged(path, muted); } else if (frame.size() >= 6) { quint32 be; - memcpy(&be, frame.constData() + 2, 4); - union { - float f; - quint32 i; - } val; - val.i = qFromBigEndian(be); + std::memcpy(&be, frame.constData() + 2, 4); + quint32 hostBits = qFromBigEndian(be); + float f; + std::memcpy(&f, &hostBits, 4); QString path = QString("/dca/%1/fader").arg(dcaIndex + 1); - m_parameterCache[path] = val.f; - emit parameterChanged(path, val.f); + m_parameterCache[path] = f; + emit parameterChanged(path, f); } } break; diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index 369b770..d00c522 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -256,7 +256,8 @@ void WingProtocol::onTransportError(const QString& error) { setStatus("Reconnection failed - max attempts reached"); setConnectionState(ConnectionState::Disconnected); } else { - int delay = m_reconnectDelayMs * (1 << m_reconnectAttempts); + int shift = std::min(m_reconnectAttempts, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); } } @@ -292,7 +293,8 @@ void WingProtocol::onConnectionTimeout() { setConnectionState(ConnectionState::Disconnected); emit connectionError("Failed to reconnect after maximum attempts"); } else { - int delay = m_reconnectDelayMs * (1 << m_reconnectAttempts); + int shift = std::min(m_reconnectAttempts, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); } } @@ -336,7 +338,8 @@ void WingProtocol::onReconnectAttempt() { m_transport.disconnect(); if (!m_transport.connect(m_host, m_port)) { - int delay = m_reconnectDelayMs * (1 << (m_reconnectAttempts - 1)); + int shift = std::min(m_reconnectAttempts - 1, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); return; } diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index a8cd78d..ca506d3 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -256,7 +256,8 @@ void X32Protocol::onTransportError(const QString& error) { setStatus("Reconnection failed - max attempts reached"); setConnectionState(ConnectionState::Disconnected); } else { - int delay = m_reconnectDelayMs * (1 << m_reconnectAttempts); + int shift = std::min(m_reconnectAttempts, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); } } @@ -291,7 +292,8 @@ void X32Protocol::onConnectionTimeout() { setConnectionState(ConnectionState::Disconnected); emit connectionError("Failed to reconnect after maximum attempts"); } else { - int delay = m_reconnectDelayMs * (1 << m_reconnectAttempts); + int shift = std::min(m_reconnectAttempts, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); } } @@ -335,7 +337,8 @@ void X32Protocol::onReconnectAttempt() { m_transport.disconnect(); if (!m_transport.connect(m_host, m_port)) { - int delay = m_reconnectDelayMs * (1 << (m_reconnectAttempts - 1)); + int shift = std::min(m_reconnectAttempts - 1, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); return; } diff --git a/src/protocol/transport/OscTransport.cpp b/src/protocol/transport/OscTransport.cpp index 9811d23..7ac726c 100644 --- a/src/protocol/transport/OscTransport.cpp +++ b/src/protocol/transport/OscTransport.cpp @@ -188,6 +188,8 @@ QVariant OscTransport::parseOscArgument(const QByteArray& data, int& offset, cha (static_cast(data[offset + 1]) << 16) | (static_cast(data[offset + 2]) << 8) | static_cast(data[offset + 3]); + if (size < 0) + break; offset += 4; if (offset + size <= data.size()) { QByteArray blob = data.mid(offset, size); diff --git a/src/protocol/yamaha/YamahaProtocol.cpp b/src/protocol/yamaha/YamahaProtocol.cpp index e03e10f..8e69f93 100644 --- a/src/protocol/yamaha/YamahaProtocol.cpp +++ b/src/protocol/yamaha/YamahaProtocol.cpp @@ -275,7 +275,8 @@ void YamahaProtocol::onConnectionTimeout() { setConnectionState(ConnectionState::Disconnected); emit connectionError("Failed to reconnect after maximum attempts"); } else { - int delay = m_reconnectDelayMs * (1 << m_reconnectAttempts); + int shift = std::min(m_reconnectAttempts, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); } } @@ -328,7 +329,8 @@ void YamahaProtocol::onReconnectAttempt() { m_oscAddress = lo_address_new(m_host.toUtf8().constData(), QString::number(m_transmitPort).toUtf8().constData()); if (!m_oscAddress) { - int delay = m_reconnectDelayMs * (1 << (m_reconnectAttempts - 1)); + int shift = std::min(m_reconnectAttempts - 1, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); return; } @@ -336,7 +338,8 @@ void YamahaProtocol::onReconnectAttempt() { if (!m_receiveSocket.bind(QHostAddress::Any, m_port)) { lo_address_free(m_oscAddress); m_oscAddress = nullptr; - int delay = m_reconnectDelayMs * (1 << (m_reconnectAttempts - 1)); + int shift = std::min(m_reconnectAttempts - 1, 10); + int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); m_reconnectTimer.start(delay); return; } @@ -456,6 +459,8 @@ QVariant YamahaProtocol::parseOscArgument(const QByteArray& data, int& offset, c (static_cast(data[offset + 1]) << 16) | (static_cast(data[offset + 2]) << 8) | static_cast(data[offset + 3]); + if (size < 0) + break; offset += 4; if (offset + size <= data.size()) { QByteArray blob = data.mid(offset, size); diff --git a/src/protocol/yamaha/YamahaProtocol.h b/src/protocol/yamaha/YamahaProtocol.h index 811b83a..6ab9139 100644 --- a/src/protocol/yamaha/YamahaProtocol.h +++ b/src/protocol/yamaha/YamahaProtocol.h @@ -73,12 +73,14 @@ class YamahaProtocol : public MixerProtocol { void onRequestTimeoutCheck(); void onReconnectAttempt(); + protected: + void parseOscMessage(const QByteArray& data); + private: void sendOscMessage(const QString& path); void sendOscMessage(const QString& path, float value); void sendOscMessage(const QString& path, int value); void sendOscMessage(const QString& path, const QString& value); - void parseOscMessage(const QByteArray& data); QVariant parseOscArgument(const QByteArray& data, int& offset, char type); void processResponse(const QString& path, const QVariant& value); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2349a75..91f1048 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,4 @@ -find_package(Qt6 REQUIRED COMPONENTS Test) +find_package(Qt6 REQUIRED COMPONENTS Test Network Widgets) add_executable(test_cue test_cue.cpp @@ -26,3 +26,87 @@ target_include_directories(test_cuelist PRIVATE ${CMAKE_SOURCE_DIR}/src ) add_test(NAME CueListTest COMMAND test_cuelist) + +add_executable(test_show + test_show.cpp + ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp +) +target_link_libraries(test_show PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_show PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME ShowTest COMMAND test_show) + +add_executable(test_undo_commands + test_undo_commands.cpp + ${CMAKE_SOURCE_DIR}/src/core/UndoCommands.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp +) +target_link_libraries(test_undo_commands PRIVATE + Qt6::Core + Qt6::Widgets + Qt6::Test +) +target_include_directories(test_undo_commands PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME UndoCommandsTest COMMAND test_undo_commands) + +add_executable(test_allenheath_parsing + test_allenheath_parsing.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathTcpProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp +) +target_link_libraries(test_allenheath_parsing PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test +) +target_include_directories(test_allenheath_parsing PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +if(WIN32) + target_compile_definitions(test_allenheath_parsing PRIVATE NOMINMAX) +endif() +add_test(NAME AllenHeathParsingTest COMMAND test_allenheath_parsing) + +add_executable(test_yamaha_osc + test_yamaha_osc.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp +) +target_link_libraries(test_yamaha_osc PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test +) +if(WIN32) + target_include_directories(test_yamaha_osc PRIVATE ${LIBLO_INCLUDE_DIRS}) + target_link_libraries(test_yamaha_osc PRIVATE ${LIBLO_LIBRARIES}) +else() + target_link_libraries(test_yamaha_osc PRIVATE PkgConfig::LIBLO) +endif() +target_include_directories(test_yamaha_osc PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +if(WIN32) + target_compile_definitions(test_yamaha_osc PRIVATE NOMINMAX) +endif() +add_test(NAME YamahaOscTest COMMAND test_yamaha_osc) diff --git a/tests/test_allenheath_parsing.cpp b/tests/test_allenheath_parsing.cpp new file mode 100644 index 0000000..f877716 --- /dev/null +++ b/tests/test_allenheath_parsing.cpp @@ -0,0 +1,103 @@ +#include "protocol/allenheath/AllenHeathTcpProtocol.h" +#include +#include +#include +#include + +using namespace OpenMix; + +class ParseableAllenHeathProtocol : public AllenHeathTcpProtocol { + Q_OBJECT + public: + explicit ParseableAllenHeathProtocol(QObject* parent = nullptr) + : AllenHeathTcpProtocol(MixerCapabilities{}, parent) {} + + void parseData(const QByteArray& data) { parseProtocolData(data); } + + protected: + void initializeSnapshotParams() override {} +}; + +class TestAllenHeathParsing : public QObject { + Q_OBJECT + + private: + static QByteArray makeParameterFloatFrame(const QString& path, float value) { + QByteArray pathBytes = path.toUtf8(); + QByteArray frame; + frame.append(static_cast(0x02)); // TYPE_PARAMETER + frame.append(static_cast(pathBytes.size())); + frame.append(pathBytes); + frame.append(static_cast(0x01)); // float indicator + + quint32 bits; + std::memcpy(&bits, &value, 4); + quint32 be = qToBigEndian(bits); + frame.append(reinterpret_cast(&be), 4); + + QByteArray msg; + quint16 len = static_cast(frame.size()); + msg.append(static_cast((len >> 8) & 0xFF)); + msg.append(static_cast(len & 0xFF)); + msg.append(frame); + return msg; + } + + static QByteArray makeDcaFaderFrame(int dcaIndex0, float level) { + QByteArray frame; + frame.append(static_cast(0x10)); // TYPE_DCA + frame.append(static_cast(dcaIndex0)); + + quint32 bits; + std::memcpy(&bits, &level, 4); + quint32 be = qToBigEndian(bits); + frame.append(reinterpret_cast(&be), 4); + + QByteArray msg; + quint16 len = static_cast(frame.size()); + msg.append(static_cast((len >> 8) & 0xFF)); + msg.append(static_cast(len & 0xFF)); + msg.append(frame); + return msg; + } + + private slots: + void typeParameter_floatRoundTrip() { + ParseableAllenHeathProtocol proto; + QSignalSpy spy(&proto, &ParseableAllenHeathProtocol::parameterChanged); + + proto.parseData(makeParameterFloatFrame("/ch/01/mix/fader", 0.75f)); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toString(), QString("/ch/01/mix/fader")); + QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.75f) < 1e-6f); + } + + void dcaFader_floatRoundTrip() { + ParseableAllenHeathProtocol proto; + QSignalSpy spy(&proto, &ParseableAllenHeathProtocol::parameterChanged); + + proto.parseData(makeDcaFaderFrame(0, 0.5f)); // DCA 1 (0-indexed: 0) + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toString(), QString("/dca/1/fader")); + QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.5f) < 1e-6f); + } + + void typeParameter_multipleFrames_parsedInOrder() { + ParseableAllenHeathProtocol proto; + QSignalSpy spy(&proto, &ParseableAllenHeathProtocol::parameterChanged); + + QByteArray combined; + combined.append(makeParameterFloatFrame("/dca/1/fader", 0.25f)); + combined.append(makeParameterFloatFrame("/dca/2/fader", 0.50f)); + proto.parseData(combined); + + QCOMPARE(spy.count(), 2); + QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.25f) < 1e-6f); + QVERIFY(qAbs(spy.at(1).at(1).toFloat() - 0.50f) < 1e-6f); + } +}; + +QTEST_MAIN(TestAllenHeathParsing) +#include "test_allenheath_parsing.moc" diff --git a/tests/test_show.cpp b/tests/test_show.cpp new file mode 100644 index 0000000..8d0f8d7 --- /dev/null +++ b/tests/test_show.cpp @@ -0,0 +1,119 @@ +#include "core/Show.h" +#include +#include + +using namespace OpenMix; + +class TestShow : public QObject { + Q_OBJECT + + private slots: + void setName_marksShowDirty() { + Show show; + QVERIFY(!show.isModified()); + QSignalSpy spy(&show, &Show::modifiedChanged); + + show.setName("Concert Night"); + + QVERIFY(show.isModified()); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), true); + } + + void setAuthor_marksShowDirty() { + Show show; + QVERIFY(!show.isModified()); + QSignalSpy spy(&show, &Show::modifiedChanged); + + show.setAuthor("Jane"); + + QVERIFY(show.isModified()); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), true); + } + + void setNotes_marksShowDirty() { + Show show; + QVERIFY(!show.isModified()); + QSignalSpy spy(&show, &Show::modifiedChanged); + + show.setNotes("Pre-show notes"); + + QVERIFY(show.isModified()); + QCOMPARE(spy.count(), 1); + } + + void setMixerConfig_marksShowDirty() { + Show show; + QVERIFY(!show.isModified()); + QSignalSpy spy(&show, &Show::modifiedChanged); + + MixerConfig config; + config.type = "wing"; + config.host = "192.168.1.100"; + config.port = 10023; + show.setMixerConfig(config); + + QVERIFY(show.isModified()); + QCOMPARE(spy.count(), 1); + } + + void setFilePath_doesNotMarkDirty() { + Show show; + QVERIFY(!show.isModified()); + QSignalSpy spy(&show, &Show::modifiedChanged); + + show.setFilePath("/path/to/show.omx"); + + QVERIFY(!show.isModified()); + QCOMPARE(spy.count(), 0); + } + + void fromJson_clearsDirtyFlag() { + Show show; + show.setName("Dirty Show"); + QVERIFY(show.isModified()); + + QJsonObject json = show.toJson(); + show.fromJson(json); + + QVERIFY(!show.isModified()); + } + + void newShow_clearsDirtyFlag() { + Show show; + show.setName("Something"); + QVERIFY(show.isModified()); + + show.newShow(); + + QVERIFY(!show.isModified()); + } + + void modifiedChanged_notEmittedAgain_whenAlreadyDirty() { + Show show; + show.setName("First"); + QVERIFY(show.isModified()); + + QSignalSpy spy(&show, &Show::modifiedChanged); + show.setName("Second"); + + QCOMPARE(spy.count(), 0); + } + + void setModified_false_clearsDirtyAndEmitsSignal() { + Show show; + show.setName("Something"); + QVERIFY(show.isModified()); + + QSignalSpy spy(&show, &Show::modifiedChanged); + show.setModified(false); + + QVERIFY(!show.isModified()); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), false); + } +}; + +QTEST_MAIN(TestShow) +#include "test_show.moc" diff --git a/tests/test_undo_commands.cpp b/tests/test_undo_commands.cpp new file mode 100644 index 0000000..3c8b0b7 --- /dev/null +++ b/tests/test_undo_commands.cpp @@ -0,0 +1,64 @@ +#include "core/CueList.h" +#include "core/UndoCommands.h" +#include + +using namespace OpenMix; + +class TestUndoCommands : public QObject { + Q_OBJECT + + private slots: + void addCueUndo_onEmptyList_doesNotCrash() { + CueList list; + // m_index = -1 means "append"; when list is empty, undo idx = count()-1 = -1 + AddCueCommand cmd(&list, Cue(1.0, "Test"), -1); + cmd.undo(); + QCOMPARE(list.count(), 0); + } + + void addCueUndo_removesAddedCue() { + CueList list; + list.addCue(Cue(1.0, "Existing")); + + // simulate: cue at index 0 was added before command was pushed + AddCueCommand cmd(&list, Cue(1.0, "Existing"), 0); + cmd.undo(); + QCOMPARE(list.count(), 0); + } + + void batchEditCommand_undoRedo_worksCorrectly() { + CueList list; + list.addCue(Cue(1.0, "Old 1")); + list.addCue(Cue(2.0, "Old 2")); + + QVector oldCues = {list.at(0), list.at(1)}; + + Cue newCue1 = oldCues[0]; + newCue1.setName("New 1"); + Cue newCue2 = oldCues[1]; + newCue2.setName("New 2"); + QVector newCues = {newCue1, newCue2}; + + // apply edit externally (simulates what the app does before pushing to stack) + list.updateCue(0, newCues[0]); + list.updateCue(1, newCues[1]); + + QVector indices = {0, 1}; + BatchEditCommand cmd(&list, indices, oldCues, newCues, "Batch rename"); + + // first redo is a no-op (simulates push to QUndoStack) + cmd.redo(); + QCOMPARE(list.at(0).name(), QString("New 1")); + + cmd.undo(); + QCOMPARE(list.at(0).name(), QString("Old 1")); + QCOMPARE(list.at(1).name(), QString("Old 2")); + + cmd.redo(); + QCOMPARE(list.at(0).name(), QString("New 1")); + QCOMPARE(list.at(1).name(), QString("New 2")); + } +}; + +QTEST_MAIN(TestUndoCommands) +#include "test_undo_commands.moc" diff --git a/tests/test_yamaha_osc.cpp b/tests/test_yamaha_osc.cpp new file mode 100644 index 0000000..afcf795 --- /dev/null +++ b/tests/test_yamaha_osc.cpp @@ -0,0 +1,80 @@ +#include "protocol/yamaha/YamahaProtocol.h" +#include +#include + +using namespace OpenMix; + +class TestYamahaProtocol : public YamahaProtocol { + Q_OBJECT + public: + explicit TestYamahaProtocol(QObject* parent = nullptr) + : YamahaProtocol(MixerCapabilities{}, parent) {} + + void parseOscData(const QByteArray& data) { parseOscMessage(data); } + + protected: + void initializeSnapshotParams() override {} +}; + +// builds an OSC message with a single blob argument whose declared size is negative. +// path is 4-byte aligned; type tag ",b" is 4-byte aligned; then 4 bytes of big-endian int32. +static QByteArray makeMalformedBlobMessage(const QString& path, qint32 blobSize) { + QByteArray data; + + QByteArray pathBytes = path.toUtf8(); + pathBytes.append('\0'); + while (pathBytes.size() % 4 != 0) + pathBytes.append('\0'); + data.append(pathBytes); + + QByteArray typeTag(",b\0", 3); + while (typeTag.size() % 4 != 0) + typeTag.append('\0'); + data.append(typeTag); + + data.append(static_cast((blobSize >> 24) & 0xFF)); + data.append(static_cast((blobSize >> 16) & 0xFF)); + data.append(static_cast((blobSize >> 8) & 0xFF)); + data.append(static_cast(blobSize & 0xFF)); + + return data; +} + +class TestYamahaOsc : public QObject { + Q_OBJECT + + private slots: + void negativeBlobSize_doesNotEmitParameterChanged() { + TestYamahaProtocol proto; + QSignalSpy spy(&proto, &TestYamahaProtocol::parameterChanged); + + proto.parseOscData(makeMalformedBlobMessage("/test/blob", -1)); + + QCOMPARE(spy.count(), 0); + } + + void validFloat_emitsParameterChanged() { + TestYamahaProtocol proto; + QSignalSpy spy(&proto, &TestYamahaProtocol::parameterChanged); + + // build valid OSC float message: path "/dca/1/fader", type 'f', value 0.75 + QByteArray data; + QByteArray pathBytes("/dca/1/fader\0\0\0\0", 16); // 12 chars + null + 3 pads = 16 + data.append(pathBytes); + data.append(",f\0\0", 4); // type tag + // 0.75f as big-endian + quint32 bits; + float v = 0.75f; + std::memcpy(&bits, &v, 4); + quint32 be = qToBigEndian(bits); + data.append(reinterpret_cast(&be), 4); + + proto.parseOscData(data); + + QCOMPARE(spy.count(), 1); + QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.75f) < 1e-6f); + } +}; + +QTEST_MAIN(TestYamahaOsc) +#include "test_yamaha_osc.moc"