// Test locale del core di harbour-hermes (compilato con Qt desktop). // // Uso: // ./core-test -> solo unit test del parser SSE // ./core-test --live -> integrazione completa contro l'istanza di prova // del WebUI su http://127.0.0.1:8899 // (login -> nuova sessione -> turno -> TTS -> STT) // // Il test live legge la password di prova da /tmp/hermes-probe/pw e usa // l'audio di prova /tmp/hermes-probe/out/prova.ogg. #include #include #include #include #include "apiclient.h" #include "chatmodel.h" #include "markdown.h" #include "settings.h" #include "sseparser.h" static int failures = 0; #define CHECK(cond, msg) \ do { \ if (!(cond)) { \ qWarning() << "FAIL:" << msg; \ ++failures; \ } else { \ qDebug() << "ok :" << msg; \ } \ } while (0) static void testParser() { qDebug() << "== unit: parser SSE =="; const QByteArray sample = "id: s1:1\n" "event: context_status\n" "data: {\"a\": 1}\n" "\n" ": heartbeat\n" "\n" "id: s1:2\n" "event: token\n" "data: {\"text\": \"p\"}\n" "\n"; SseParser p; const QList evs = p.feed(sample); CHECK(evs.size() == 2, "due eventi, keepalive ignorato"); if (evs.size() == 2) { CHECK(evs.at(0).name == QLatin1String("context_status"), "primo evento context_status"); CHECK(evs.at(0).id == QLatin1String("s1:1"), "id del primo evento"); CHECK(evs.at(1).name == QLatin1String("token"), "secondo evento token"); CHECK(evs.at(1).data == QLatin1String("{\"text\": \"p\"}"), "data del token"); } // Chunk spezzati a meta' evento (caso reale della rete). SseParser p2; CHECK(p2.feed("event: token\ndata: {\"te").isEmpty(), "chunk parziale: nessun evento"); const QList evs2 = p2.feed("xt\": \"ok\"}\n\n"); CHECK(evs2.size() == 1, "evento completato dal secondo chunk"); if (!evs2.isEmpty()) CHECK(evs2.at(0).data.contains(QLatin1String("ok")), "payload ricomposto"); // Evento con data su piu' righe. SseParser p3; const QList evs3 = p3.feed("event: x\ndata: a\ndata: b\n\n"); CHECK(evs3.size() == 1 && evs3.at(0).data == QLatin1String("a\nb"), "data multi-riga unito con newline"); } static QString readFileTrim(const QString &path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return QString(); return QString::fromUtf8(f.readAll()).trimmed(); } static void testMarkdown() { qDebug() << "== unit: markdown =="; CHECK(Markdown::toRichText("**ciao**") == QLatin1String("ciao"), "grassetto -> "); CHECK(Markdown::toRichText("__ciao__") == QLatin1String("ciao"), "grassetto con underscore -> "); CHECK(Markdown::toRichText("*ciao*") == QLatin1String("ciao"), "corsivo -> "); CHECK(Markdown::toRichText("# Titolo") == QLatin1String("Titolo"), "titolo -> grassetto"); CHECK(Markdown::toRichText("- uno\n- due") == QStringLiteral("• uno
• due"), "lista puntata -> bullet"); CHECK(Markdown::toRichText("1. uno\n2. due") == QLatin1String("1. uno
2. due"), "lista numerata conservata"); CHECK(Markdown::toRichText("`x = 1`").contains(QLatin1String("monospace")), "codice inline -> monospace"); CHECK(Markdown::toRichText("[Hermes](https://x.example)") == QLatin1String("Hermes"), "link -> solo testo"); CHECK(Markdown::toRichText("a < b & c") == QLatin1String("a < b & c"), "escape di < e &"); CHECK(Markdown::toRichText("2 * 3 = 6") == QLatin1String("2 * 3 = 6"), "asterisco spaiato lasciato intatto"); CHECK(Markdown::toRichText("> citazione") == QStringLiteral("▏ citazione"), "citazione con barretta"); CHECK(Markdown::toRichText("```\nint x;\n```").contains(QLatin1String("int x;")), "blocco codice: contenuto mostrato"); CHECK(Markdown::toSpeech("**ciao** *a* `b`") == QLatin1String("ciao a b"), "speech: via enfasi e backtick"); CHECK(Markdown::toSpeech("# Titolo\n- uno\n> due") == QLatin1String("Titolo\nuno\ndue"), "speech: titoli/liste/citazioni senza marcatori"); CHECK(Markdown::toSpeech("[t](https://x) e ![img](https://y)") == QLatin1String("t e img"), "speech: link e immagini -> testo"); CHECK(Markdown::toSpeech("snake_case resta") == QLatin1String("snake_case resta"), "speech: underscore in identificatori intatti"); CHECK(Markdown::toSpeech("| a | b |\n|---|---|\n| c | d |") == QLatin1String("a, b\nc, d"), "speech: tabella compattata"); CHECK(Markdown::toSpeech("```\ncodice()\n```") == QLatin1String("codice()"), "speech: blocco codice senza recinti"); CHECK(Markdown::toSpeech("**grassetto**\n\n\n\n*testo*") == QLatin1String("grassetto\n\ntesto"), "speech: righe vuote compattate"); } static void testSettingsPersistence() { qDebug() << "== unit: persistenza impostazioni =="; const QString cfg = Settings::configPath(); qDebug() << "file di test:" << cfg; QFile::remove(cfg); // stato pulito: nessun valore precedente { Settings s1; s1.setBaseUrl(QStringLiteral("https://esempio.test")); s1.setTtsVoice(QStringLiteral("it-IT-ElsaNeural")); } { // Una SECONDA istanza rilegge dallo stesso file: prova che i valori // sono finiti su disco (non solo in memoria) -> persistenza vera. Settings s2; CHECK(s2.baseUrl() == QLatin1String("https://esempio.test"), "baseUrl riletto da una nuova istanza (passa dal file)"); CHECK(s2.ttsVoice() == QLatin1String("it-IT-ElsaNeural"), "voce riletta da una nuova istanza (passa dal file)"); } } int main(int argc, char **argv) { QCoreApplication app(argc, argv); app.setOrganizationName(QStringLiteral("harbour-test")); app.setApplicationName(QStringLiteral("hermes-test")); testParser(); testMarkdown(); testSettingsPersistence(); if (!app.arguments().contains(QStringLiteral("--live"))) { qDebug() << (failures == 0 ? "UNIT: tutti i test passati" : "UNIT: FALLIMENTI"); return failures == 0 ? 0 : 1; } qDebug() << "== integrazione live (probe :8899) =="; QTimer::singleShot(180000, &app, []() { qWarning() << "TIMEOUT globale del test live"; qApp->exit(2); }); Settings settings; settings.setBaseUrl(QStringLiteral("http://127.0.0.1:8899")); ApiClient api(&settings); ChatModel chat(&api); const QString pw = readFileTrim(QStringLiteral("/tmp/hermes-probe/pw")); if (pw.isEmpty()) { qWarning() << "password del probe mancante in /tmp/hermes-probe/pw"; return 1; } bool chatSent = false; bool chatDone = false; bool ttsDone = false; bool sttDone = false; QObject::connect(&api, &ApiClient::loggedInChanged, [&]() { if (api.loggedIn()) { qDebug() << "LIVE: login ok"; chat.newSession(); } }); QObject::connect(&api, &ApiClient::sessionOpened, [&]() { if (chatSent) return; chatSent = true; qDebug() << "LIVE: sessione" << chat.sessionId(); chat.send(QStringLiteral("Rispondi solo con la parola pong. Non usare strumenti.")); }); QObject::connect(&chat, &ChatModel::assistantFinished, [&]() { qDebug() << "LIVE: risposta =" << chat.lastAssistantText(); chatDone = chat.lastAssistantText().contains(QLatin1String("pong")); api.speak(QStringLiteral("Questa e' la voce italiana del client Hermes per Sailfish.")); }); QObject::connect(&api, &ApiClient::ttsReady, [&](const QString &path) { QFile f(path); ttsDone = f.size() > 1000; qDebug() << "LIVE: ttsReady" << path << "bytes:" << f.size(); api.transcribeFile(QStringLiteral("/tmp/hermes-probe/out/prova.ogg")); }); QObject::connect(&api, &ApiClient::ttsFailed, [&](const QString &err) { qWarning() << "LIVE: ttsFailed" << err; api.transcribeFile(QStringLiteral("/tmp/hermes-probe/out/prova.ogg")); }); QObject::connect(&api, &ApiClient::transcriptionReady, [&](const QString &text) { qDebug() << "LIVE: trascrizione =" << text; sttDone = !text.isEmpty(); QTimer::singleShot(200, qApp, [&]() { const bool pass = chatDone && ttsDone && sttDone; qDebug() << (pass ? "LIVE: TUTTI I PASSAGGI OK" : "LIVE: qualche passaggio e' fallito"); qApp->exit(pass ? 0 : 1); }); }); QObject::connect(&api, &ApiClient::transcriptionFailed, [&](const QString &err) { qWarning() << "LIVE: transcriptionFailed" << err; qApp->exit(1); }); QObject::connect(&api, &ApiClient::chatStartFailed, [&](const QString &err) { qWarning() << "LIVE: chatStartFailed" << err; qApp->exit(1); }); api.login(pw); qDebug() << "LIVE: login inviato"; return app.exec(); }