Files
harbour-hermes/tests/core_test.cpp
T
kaneda db75d5fea1 fix(settings): le impostazioni ora persistono davvero dal launcher (v0.5.3)
Causa (il "solito" problema dei progetti Sailfish): QSettings di default
scrive in ~/.config/harbour/hermes.conf, FUORI dalla directory che la
sandbox SailJail rende persistente (~/.config/harbour/hermes/). Dal
launcher le modifiche finivano in una vista effimera e sparivano a ogni
riavvio; da terminale invece restavano (da qui l'effetto "a volte si' a
volte no"). Stesso problema gia' risolto in harbour-calibreweb e
harbour-quill con il percorso esplicito.

- Settings: QSettings(configPath(), IniFormat) con configPath() =
  AppConfigLocation + "/hermes.conf" (mkpath della directory)
- store(): ogni setter fa setValue + sync immediato (nessuna modifica
  persa se il sistema termina l'app)
- migrateIfNeeded(): al primo avvio importa i valori rimasti nel vecchio
  percorso di default (~/.config/harbour/hermes.conf, piu' i path legacy
  nemo/defaultuser)
- diagnostica: log del file impostazioni e dei valori letti all'avvio
- core-test: test di persistenza (scrittura con una istanza, rilettura
  con una SECONDA istanza: prova che passa dal file su disco)
2026-09-13 13:53:17 +02:00

237 lines
9.3 KiB
C++

// 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 <QCoreApplication>
#include <QDebug>
#include <QFile>
#include <QTimer>
#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<SseParser::Event> 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<SseParser::Event> 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<SseParser::Event> 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("<b>ciao</b>"),
"grassetto -> <b>");
CHECK(Markdown::toRichText("__ciao__") == QLatin1String("<b>ciao</b>"),
"grassetto con underscore -> <b>");
CHECK(Markdown::toRichText("*ciao*") == QLatin1String("<i>ciao</i>"),
"corsivo -> <i>");
CHECK(Markdown::toRichText("# Titolo") == QLatin1String("<b>Titolo</b>"),
"titolo -> grassetto");
CHECK(Markdown::toRichText("- uno\n- due") == QStringLiteral("• uno<br/>• due"),
"lista puntata -> bullet");
CHECK(Markdown::toRichText("1. uno\n2. due") == QLatin1String("1. uno<br/>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 &lt; b &amp; 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();
}