From ac1281c1d9aa292fae553518dfaa60bdc4cef64a Mon Sep 17 00:00:00 2001
From: sawinglogz <3787776-sawinglogz@users.noreply.gitlab.com>
Date: Thu, 11 Jun 2020 15:58:34 -0400
Subject: [PATCH] Add playback of serial port scan, along with supporting
 infrastructure.

---
 oscar/SleepLib/deviceconnection.cpp   | 399 ++++++++++++++++++++++++--
 oscar/SleepLib/deviceconnection.h     |  22 +-
 oscar/tests/deviceconnectiontests.cpp |  38 ++-
 3 files changed, 417 insertions(+), 42 deletions(-)

diff --git a/oscar/SleepLib/deviceconnection.cpp b/oscar/SleepLib/deviceconnection.cpp
index c5d9e0f5..48153d6f 100644
--- a/oscar/SleepLib/deviceconnection.cpp
+++ b/oscar/SleepLib/deviceconnection.cpp
@@ -8,6 +8,8 @@
 
 #include "deviceconnection.h"
 #include <QtSerialPort/QSerialPortInfo>
+#include <QFile>
+#include <QBuffer>
 #include <QDateTime>
 #include <QDebug>
 
@@ -31,63 +33,393 @@ DeviceConnectionManager::DeviceConnectionManager()
 {
 }
 
-void DeviceConnectionManager::Record(QXmlStreamWriter* stream)
+// MARK: -
+
+class XmlRecord
 {
-    getInstance().m_record = stream;
+public:
+    XmlRecord(class QFile * file);
+    XmlRecord(QString & string);
+    ~XmlRecord();
+    inline QXmlStreamWriter & xml() { return *m_xml; }
+protected:
+    QFile* m_file;  // nullptr for non-file recordings
+    QXmlStreamWriter* m_xml;
+    
+    void prologue();
+    void epilogue();
+};
+
+class XmlReplay
+{
+public:
+    XmlReplay(class QFile * file);
+    XmlReplay(QXmlStreamReader & xml);
+    ~XmlReplay();
+    template<class T> inline T* getNextEvent();
+
+protected:
+    void deserialize(QXmlStreamReader & xml);
+    void deserializeEvents(QXmlStreamReader & xml);
+
+    // TODO: maybe the QList should be a QHash on the timestamp?
+    // Then indices would be iterators over a sorted list of keys.
+    QHash<QString,QList<class XmlReplayEvent*>> m_events;
+    QHash<QString,int> m_indices;
+
+    class XmlReplayEvent* getNextEvent(const QString & type);
+};
+
+class XmlReplayEvent
+{
+public:
+    XmlReplayEvent();
+    virtual ~XmlReplayEvent() = default;
+    virtual const QString & tag() const = 0;
+
+    void record(XmlRecord* xml);
+    friend QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const XmlReplayEvent & event);
+    friend QXmlStreamReader & operator>>(QXmlStreamReader & xml, XmlReplayEvent & event);
+
+    typedef XmlReplayEvent* (*FactoryMethod)();
+    static bool registerClass(const QString & tag, FactoryMethod factory);
+    static XmlReplayEvent* createInstance(const QString & tag);
+
+protected:
+    static QHash<QString,FactoryMethod> s_factories;
+
+    QDateTime m_time;
+
+    virtual void write(QXmlStreamWriter & /*xml*/) const {}
+    virtual void read(QXmlStreamReader & /*xml*/) {}
+};
+QHash<QString,XmlReplayEvent::FactoryMethod> XmlReplayEvent::s_factories;
+
+
+XmlRecord::XmlRecord(QFile* stream)
+    : m_file(stream), m_xml(new QXmlStreamWriter(stream))
+{
+    prologue();
 }
 
-void DeviceConnectionManager::Replay(QXmlStreamReader* stream)
+XmlRecord::XmlRecord(QString & string)
+    : m_file(nullptr), m_xml(new QXmlStreamWriter(&string))
 {
-    getInstance().m_replay = stream;
+    prologue();
 }
 
-void DeviceConnectionManager::startEvent(const QString & event)
+XmlRecord::~XmlRecord()
 {
-    if (m_record) {
-        QDateTime now = QDateTime::currentDateTime();
-        now = now.toOffsetFromUtc(now.offsetFromUtc());  // force display of UTC offset
+    epilogue();
+    delete m_xml;
+}
+
+void XmlRecord::prologue()
+{
+    Q_ASSERT(m_xml);
+    m_xml->setAutoFormatting(true);
+    m_xml->setAutoFormattingIndent(2);
+    
+    m_xml->writeStartElement("xmlreplay");
+    m_xml->writeStartElement("events");
+}
+
+void XmlRecord::epilogue()
+{
+    Q_ASSERT(m_xml);
+    m_xml->writeEndElement();  // close events
+    // TODO: write out any inline connections
+    m_xml->writeEndElement();  // close xmlreplay
+}
+
+XmlReplay::XmlReplay(QFile* file)
+{
+    QXmlStreamReader xml(file);
+    deserialize(xml);
+}
+
+XmlReplay::XmlReplay(QXmlStreamReader & xml)
+{
+    deserialize(xml);
+}
+
+XmlReplay::~XmlReplay()
+{
+    for (auto list : m_events.values()) {
+        for (auto event : list) {
+            delete event;
+        }
+    }
+}
+
+void XmlReplay::deserialize(QXmlStreamReader & xml)
+{
+    if (xml.readNextStartElement()) {
+        if (xml.name() == "xmlreplay") {
+            while (xml.readNextStartElement()) {
+                if (xml.name() == "events") {
+                    deserializeEvents(xml);
+                // else TODO: inline connections
+                } else {
+                    qWarning() << "unexpected payload in replay XML:" << xml.name();
+                    xml.skipCurrentElement();
+                }
+            }
+        }
+    }
+}
+
+void XmlReplay::deserializeEvents(QXmlStreamReader & xml)
+{
+    while (xml.readNextStartElement()) {
+        QString name = xml.name().toString();
+        XmlReplayEvent* event = XmlReplayEvent::createInstance(name);
+        if (event) {
+            xml >> *event;
+            auto & events = m_events[name];
+            events.append(event);
+        } else {
+            xml.skipCurrentElement();
+        }
+    }
+}
+
+XmlReplayEvent* XmlReplay::getNextEvent(const QString & type)
+{
+    XmlReplayEvent* event = nullptr;
+    
+    if (m_events.contains(type)) {
+        auto & events = m_events[type];
+        int i = m_indices[type];
+        if (i < events.size()) {
+            event = events[i];
+            // TODO: if we're simulating the original timing, return nullptr if we haven't reached this event's time yet;
+            // otherwise:
+            m_indices[type] = i + 1;
+        }
+    }
+    return event;
+}
+
+template<class T>
+T* XmlReplay::getNextEvent()
+{
+    T* event = dynamic_cast<T*>(getNextEvent(T::TAG));
+    return event;
+}
+
+
+// MARK: -
+
+XmlReplayEvent::XmlReplayEvent()
+    : m_time(QDateTime::currentDateTime())
+{
+}
+
+void XmlReplayEvent::record(XmlRecord* writer)
+{
+    // Do nothing if we're not recording.
+    if (writer != nullptr) {
+        writer->xml() << *this;
+    }
+}
+
+bool XmlReplayEvent::registerClass(const QString & tag, XmlReplayEvent::FactoryMethod factory)
+{
+    if (s_factories.contains(tag)) {
+        qWarning() << "Event class already registered for tag" << tag;
+        return false;
+    }
+    s_factories[tag] = factory;
+    return true;
+}
+
+XmlReplayEvent* XmlReplayEvent::createInstance(const QString & tag)
+{
+    XmlReplayEvent* event = nullptr;
+    XmlReplayEvent::FactoryMethod factory = s_factories.value(tag);
+    if (factory == nullptr) {
+        qWarning() << "No event class registered for XML tag" << tag;
+    } else {
+        event = factory();
+    }
+    return event;
+}
+
+QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const XmlReplayEvent & event)
+{
+    QDateTime time = event.m_time.toOffsetFromUtc(event.m_time.offsetFromUtc());  // force display of UTC offset
+#if QT_VERSION < QT_VERSION_CHECK(5,9,0)
+    // TODO: Can we please deprecate support for Qt older than 5.9?
+    QString timestamp = time.toString(Qt::ISODate);
+#else
+    QString timestamp = time.toString(Qt::ISODateWithMs);
+#endif
+    xml.writeStartElement(event.tag());
+    xml.writeAttribute("time", timestamp);
+
+    event.write(xml);
+
+    xml.writeEndElement();
+    return xml;
+}
+
+QXmlStreamReader & operator>>(QXmlStreamReader & xml, XmlReplayEvent & event)
+{
+    Q_ASSERT(xml.isStartElement() && xml.name() == event.tag());
+
+    QDateTime time;
+    if (xml.attributes().hasAttribute("time")) {
 #if QT_VERSION < QT_VERSION_CHECK(5,9,0)
         // TODO: Can we please deprecate support for Qt older than 5.9?
-        QString timestamp = now.toString(Qt::ISODate);
+        time = QDateTime::fromString(xml.attributes().value("time").toString(), Qt::ISODate);
 #else
-        QString timestamp = now.toString(Qt::ISODateWithMs);
+        time = QDateTime::fromString(xml.attributes().value("time").toString(), Qt::ISODateWithMs);
 #endif
-        m_record->writeStartElement(event);
-        m_record->writeAttribute("time", timestamp);
+    } else {
+        qWarning() << "Missing timestamp in" << xml.name() << "tag, using current time";
+        time = QDateTime::currentDateTime();
     }
+    
+    event.read(xml);
+    return xml;
 }
 
-#define RECORD(x) if (m_record) { *m_record << (x); }
+template<typename T> QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const QList<T> & list)
+{
+    for (auto & item : list) {
+        xml << item;
+    }
+    return xml;
+}
 
-void DeviceConnectionManager::endEvent()
+template<typename T> QXmlStreamReader & operator>>(QXmlStreamReader & xml, QList<T> & list)
+{
+    list.clear();
+    while (xml.readNextStartElement()) {
+        T item;
+        xml >> item;
+        list.append(item);
+    }
+    return xml;
+}
+
+template <typename Derived>
+class XmlReplayBase : public XmlReplayEvent
+{
+public:
+    static const QString TAG;
+    static const bool registered;
+    virtual const QString & tag() const { return TAG; };
+
+    static XmlReplayEvent* createInstance()
+    {
+        Derived* instance = new Derived();
+        return static_cast<XmlReplayEvent*>(instance);
+    }
+};
+
+#define REGISTER_XMLREPLAYEVENT(tag, type) \
+template<> const QString XmlReplayBase<type>::TAG = tag; \
+template<> const bool XmlReplayBase<type>::registered = XmlReplayEvent::registerClass(XmlReplayBase<type>::TAG, XmlReplayBase<type>::createInstance);
+
+
+// MARK: -
+
+void DeviceConnectionManager::record(QFile* stream)
 {
     if (m_record) {
-        m_record->writeEndElement();
+        delete m_record;
+    }
+    if (stream) {
+        m_record = new XmlRecord(stream);
+    } else {
+        // nullptr turns off recording
+        m_record = nullptr;
     }
 }
 
+void DeviceConnectionManager::record(QString & string)
+{
+    if (m_record) {
+        delete m_record;
+    }
+    m_record = new XmlRecord(string);
+}
+
+void DeviceConnectionManager::replay(const QString & string)
+{
+    QXmlStreamReader xml(string);
+    reset();
+    if (m_replay) {
+        delete m_replay;
+    }
+    m_replay = new XmlReplay(xml);
+}
+
+void DeviceConnectionManager::replay(QFile* file)
+{
+    reset();
+    if (m_replay) {
+        delete m_replay;
+    }
+    if (file) {
+        m_replay = new XmlReplay(file);
+    } else {
+        // nullptr turns off replay
+        m_replay = nullptr;
+    }
+}
+
+
+// MARK: -
+
+class GetAvailablePortsEvent : public XmlReplayBase<GetAvailablePortsEvent>
+{
+public:
+    QList<SerialPortInfo> m_ports;
+
+protected:
+    virtual void write(QXmlStreamWriter & xml) const
+    {
+        xml << m_ports;
+    }
+    virtual void read(QXmlStreamReader & xml)
+    {
+        xml >> m_ports;
+    }
+};
+REGISTER_XMLREPLAYEVENT("getAvailablePorts", GetAvailablePortsEvent);
+
+
 QList<SerialPortInfo> DeviceConnectionManager::getAvailablePorts()
 {
-    QList<SerialPortInfo> out;
+    GetAvailablePortsEvent event;
 
-    startEvent("getAvailablePorts");
-
-    if (m_replay) {
-        // TODO
-    } else {
+    if (!m_replay) {
         for (auto & info : QSerialPortInfo::availablePorts()) {
-            out.append(SerialPortInfo(info));
+            event.m_ports.append(SerialPortInfo(info));
+        }
+    } else {
+        auto replayEvent = m_replay->getNextEvent<GetAvailablePortsEvent>();
+        if (replayEvent) {
+            event.m_ports = replayEvent->m_ports;
+        } else {
+            // If there are no replay events available, reuse the most recent state.
+            event.m_ports = m_serialPorts;
         }
     }
+    m_serialPorts = event.m_ports;
 
-    for (auto & portInfo : out) {
-        //qDebug().noquote() << portInfo;
-        RECORD(portInfo);
-    }
-    endEvent();
-    return out;
+    event.record(m_record);
+    return event.m_ports;
 }
 
+
+// TODO: Once we start recording/replaying connections, we'll need to include a version number, so that
+// if we ever have to change the download code, the older replays will still work as expected.
+
+
 // MARK: -
 
 SerialPortInfo::SerialPortInfo(const QSerialPortInfo & other)
@@ -119,6 +451,10 @@ SerialPortInfo::SerialPortInfo(const QString & data)
     xml >> *this;
 }
 
+SerialPortInfo::SerialPortInfo()
+{
+}
+
 // TODO: This is a temporary wrapper until we begin refactoring.
 QList<SerialPortInfo> SerialPortInfo::availablePorts()
 {
@@ -166,7 +502,7 @@ QXmlStreamReader & operator>>(QXmlStreamReader & xml, SerialPortInfo & info)
     } else {
         qWarning() << "no <serial> tag";
     }
-    xml.readNext();
+    xml.skipCurrentElement();
     return xml;
 }
 
@@ -177,3 +513,8 @@ SerialPortInfo::operator QString() const
     xml << *this;
     return out;
 }
+
+bool SerialPortInfo::operator==(const SerialPortInfo & other) const
+{
+    return m_info == other.m_info;
+}
diff --git a/oscar/SleepLib/deviceconnection.h b/oscar/SleepLib/deviceconnection.h
index ab4c919d..24a441e7 100644
--- a/oscar/SleepLib/deviceconnection.h
+++ b/oscar/SleepLib/deviceconnection.h
@@ -15,6 +15,7 @@
 #include <QtSerialPort/QSerialPort>
 #include <QHash>
 #include <QVariant>
+#include <QDateTime>
 #include <QXmlStreamReader>
 #include <QXmlStreamWriter>
 
@@ -24,10 +25,15 @@ class DeviceConnectionManager : public QObject
 
 private:
     DeviceConnectionManager();
-    QXmlStreamWriter* m_record;
-    QXmlStreamReader* m_replay;
-    void startEvent(const QString & event);
-    void endEvent();
+    
+    class XmlRecord* m_record;
+    class XmlReplay* m_replay;
+
+    QList<class SerialPortInfo> replayAvailablePorts();
+    QList<SerialPortInfo> m_serialPorts;
+    void reset() {  // clear state
+        m_serialPorts.clear();
+    }
 
 public:
     static DeviceConnectionManager & getInstance();
@@ -36,8 +42,10 @@ public:
     // TODO: method to start a polling thread that maintains the list of ports
     // TODO: emit signal when new port is detected
 
-    static void Record(QXmlStreamWriter* stream);
-    static void Replay(QXmlStreamReader* stream);
+    void record(class QFile* stream);
+    void record(QString & string);
+    void replay(class QFile* stream);
+    void replay(const QString & string);
 
 };
 
@@ -57,6 +65,7 @@ public:
     static QList<SerialPortInfo> availablePorts();
     SerialPortInfo(const SerialPortInfo & other);
     SerialPortInfo(const QString & data);
+    SerialPortInfo();
 
     inline QString portName() const { return m_info["portName"].toString(); }
     inline QString systemLocation() const { return m_info["systemLocation"].toString(); }
@@ -75,6 +84,7 @@ public:
     operator QString() const;
     friend QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const SerialPortInfo & info);
     friend QXmlStreamReader & operator>>(QXmlStreamReader & xml, SerialPortInfo & info);
+    bool operator==(const SerialPortInfo & other) const;
 
 protected:
     SerialPortInfo(const class QSerialPortInfo & other);
diff --git a/oscar/tests/deviceconnectiontests.cpp b/oscar/tests/deviceconnectiontests.cpp
index f63db987..1d1aeb71 100644
--- a/oscar/tests/deviceconnectiontests.cpp
+++ b/oscar/tests/deviceconnectiontests.cpp
@@ -9,6 +9,8 @@
 #include "deviceconnectiontests.h"
 #include "SleepLib/deviceconnection.h"
 
+#include <QTemporaryFile>
+
 void DeviceConnectionTests::testSerialPortInfoSerialization()
 {
     QString serialized;
@@ -54,13 +56,35 @@ void DeviceConnectionTests::testSerialPortInfoSerialization()
 void DeviceConnectionTests::testSerialPortScanning()
 {
     QString string;
-    QXmlStreamWriter xml(&string);
-    xml.setAutoFormatting(true);
-
-    DeviceConnectionManager::Record(&xml);
-    SerialPortInfo::availablePorts();
-    SerialPortInfo::availablePorts();
-    DeviceConnectionManager::Record(nullptr);
 
+    DeviceConnectionManager & devices = DeviceConnectionManager::getInstance();
+    devices.record(string);
+    auto list1 = SerialPortInfo::availablePorts();
+    auto list2 = SerialPortInfo::availablePorts();
+    devices.record(nullptr);
+    // string now contains the recorded XML.
     qDebug().noquote() << string;
+
+    devices.replay(string);
+    Q_ASSERT(list1 == SerialPortInfo::availablePorts());
+    Q_ASSERT(list2 == SerialPortInfo::availablePorts());
+    Q_ASSERT(list2 == SerialPortInfo::availablePorts());  // replaying past the recording should return the final state
+    devices.replay(nullptr);  // turn off replay
+    auto list3 = SerialPortInfo::availablePorts();
+
+    // Test file-based recording/playback
+    QTemporaryFile recording;
+    Q_ASSERT(recording.open());
+    devices.record(&recording);
+    list1 = SerialPortInfo::availablePorts();
+    list2 = SerialPortInfo::availablePorts();
+    devices.record(nullptr);
+
+    recording.seek(0);
+    devices.replay(&recording);
+    Q_ASSERT(list1 == SerialPortInfo::availablePorts());
+    Q_ASSERT(list2 == SerialPortInfo::availablePorts());
+    Q_ASSERT(list2 == SerialPortInfo::availablePorts());  // replaying past the recording should return the final state
+    devices.replay(nullptr);  // turn off replay
+    list3 = SerialPortInfo::availablePorts();
 }