Files
Flora/tests/db_test.cpp
T
grendervill 51372ace4e ~sql в пузыре кодоблока, кликабельные имена и пути
- renderSqlBlock: та же рамка что у кода (Telegram-пузырь), таблица
  светлым по тёмно-синему, кнопка копирования запроса
- Имя файла (title) — ссылка flora://open (новая вкладка рядом);
  путь — ссылка flora://copycode/spath_N (клик копирует путь)
- Database::filePathForTitle: резолв имени в путь (пусто при
  отсутствии/неоднозначности)
- Тесты пузыря, open/copy ссылок и резолва
2026-09-07 17:03:07 +03:00

424 lines
20 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тест SQLite-слоя: Frontmatter, VaultSettings, Database (индекс, запросы,
// свойства) и ~sql-блоки в MdParser (таблица / fallback / ошибка).
#include <QCoreApplication>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <QUrl>
#include <cstdio>
#include "core/Frontmatter.h"
#include "core/VaultSettings.h"
#include "core/VaultIndex.h"
#include "db/Database.h"
#include "markdown/MdParser.h"
static int fail(const char *msg, const QString &detail = QString())
{
std::fprintf(stderr, "FAIL: %s | %s\n", msg, qPrintable(detail));
return 1;
}
static void writeFile(const QString &path, const QString &text)
{
QFile f(path);
f.open(QIODevice::WriteOnly | QIODevice::Truncate);
f.write(text.toUtf8());
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName("FloraTest");
QCoreApplication::setOrganizationName("FloraTest");
// --- Frontmatter: парс ---
{
Frontmatter::Parsed fp = Frontmatter::parse(
"---\nстатус: в работе\nприоритет: 1\n# коммент\nмусор без двоеточия\n---\n\nТело\n");
if (!fp.hasFrontmatter)
return fail("fm has");
if (fp.props.value("статус").toString() != "в работе")
return fail("fm статус", fp.props.value("статус").toString());
if (fp.props.value("приоритет").toString() != "1")
return fail("fm приоритет");
if (fp.raw.size() != 2)
return fail("fm raw", QString::number(fp.raw.size()));
if (!fp.body.contains("Тело"))
return fail("fm body", fp.body);
}
// --- Frontmatter: нет шапки / незакрытая (body при этом целый!) ---
{
Frontmatter::Parsed a = Frontmatter::parse("Просто текст\n");
if (a.hasFrontmatter || !a.props.isEmpty())
return fail("fm no fence");
if (a.body != "Просто текст\n")
return fail("fm body kept", a.body);
Frontmatter::Parsed b = Frontmatter::parse("---\nключ: 1\nбез закрытия\n");
if (b.hasFrontmatter)
return fail("fm unclosed = текст");
}
// --- Frontmatter: roundtrip ---
{
QVariantMap props;
props.insert("b", "2");
props.insert("a", "1");
const QString s = Frontmatter::serialize(props, QStringList() << "# raw", "Тело");
Frontmatter::Parsed fp = Frontmatter::parse(s);
if (fp.props.value("a").toString() != "1" || fp.props.value("b").toString() != "2")
return fail("fm roundtrip props", s);
if (fp.raw.size() != 1 || fp.body.trimmed() != "Тело")
return fail("fm roundtrip raw/body", s);
}
// --- VaultSettings: дефолты + persist ---
QTemporaryDir dir;
if (!dir.isValid())
return fail("temp dir");
const QString vault = dir.path();
{
VaultSettings vs;
if (!vs.open(vault))
return fail("vs open");
if (vs.journalDir() != "Журнал" || !vs.sqlEnabled() || vs.sqlMaxRows() != 50)
return fail("vs defaults");
vs.setJournalDir("../../etc"); // мусор → дефолт
if (vs.journalDir() != "Журнал")
return fail("vs sanitize", vs.journalDir());
vs.setJournalDir("Дневник");
vs.setSqlMaxRows(20);
if (!vs.save())
return fail("vs save");
VaultSettings vs2;
vs2.open(vault);
if (vs2.journalDir() != "Дневник" || vs2.sqlMaxRows() != 20)
return fail("vs roundtrip");
}
// --- Хранилище: два файла со свойствами, тегами и ссылкой ---
writeFile(vault + "/a.md",
"---\nстатус: в работе\n---\n\nТекст #тег1 [[b]]\n");
writeFile(vault + "/b.md", "# Бэ\n\nТекст #тег1 #тег2\n");
VaultIndex index;
index.rebuild(vault);
if (index.fileCount() != 2)
return fail("index count", QString::number(index.fileCount()));
Database db;
db.setIndex(&index);
if (!db.open(vault))
return fail("db open");
// open сам подхватил готовый индекс
QVariantMap st = db.stats();
if (st.value("files").toInt() != 2)
return fail("db files", QString::number(st.value("files").toInt()));
if (st.value("tags").toInt() != 2)
return fail("db tags", QString::number(st.value("tags").toInt()));
if (st.value("links").toInt() != 1)
return fail("db links", QString::number(st.value("links").toInt()));
if (st.value("properties").toInt() != 1)
return fail("db props", QString::number(st.value("properties").toInt()));
// --- SELECT работает (включая многоколоночные: строки не сплющиваются) ---
{
QVariantMap r = db.queryTable("SELECT title FROM files ORDER BY title");
if (!r.value("error").toString().isEmpty())
return fail("select err", r.value("error").toString());
if (r.value("columns").toStringList() != QStringList() << "title")
return fail("select cols", r.value("columns").toStringList().join(","));
const QVariantList rows = r.value("rows").toList();
if (rows.size() != 2)
return fail("select rows");
if (rows[0].toList().size() != 1 || rows[0].toList()[0].toString() != "a")
return fail("select cell");
}
{
QVariantMap r = db.queryTable("SELECT path, title FROM files ORDER BY title");
const QVariantList rows = r.value("rows").toList();
if (rows.size() != 2 || rows[0].toList().size() != 2)
return fail("select shape");
if (!rows[0].toList()[0].toString().endsWith("/a.md")
|| rows[0].toList()[1].toString() != "a")
return fail("select cells", rows[0].toList()[0].toString());
}
// --- Пишущие запросы режутся ---
{
QVariantMap r = db.queryTable("INSERT INTO tags (name) VALUES ('x')");
if (r.value("error").toString().isEmpty())
return fail("insert не зарезан");
QVariantMap r2 = db.queryTable("SELECT * FROM tags; DELETE FROM tags");
if (r2.value("error").toString().isEmpty())
return fail("multi не зарезан");
// SELECT с запрещённым словом внутри строки — можно (фильтр по коду, не по строке)
QVariantMap r3 = db.queryTable("SELECT 'update' AS u");
if (!r3.value("error").toString().isEmpty())
return fail("строка update зря зарезана", r3.value("error").toString());
}
// --- Свойства: чтение + запись ---
{
QVariantMap p = db.fileProperties(vault + "/a.md");
if (p.value("статус").toString() != "в работе")
return fail("props read");
QVariantMap np;
np.insert("статус", "готово");
np.insert("оценка", "5");
const QString err = db.saveFileProperties(vault + "/a.md", np);
if (!err.isEmpty())
return fail("props save", err);
index.updateFile(vault + "/a.md");
db.syncFile(vault + "/a.md");
QVariantMap p2 = db.fileProperties(vault + "/a.md");
if (p2.value("статус").toString() != "готово" || p2.value("оценка").toString() != "5")
return fail("props roundtrip");
// тело и ссылка не пострадали
QFile f(vault + "/a.md");
f.open(QIODevice::ReadOnly);
const QString t = QString::fromUtf8(f.readAll());
if (!t.contains("[[b]]") || !t.contains("#тег1"))
return fail("props body", t);
// плохое имя свойства
QVariantMap bad;
bad.insert("плохо имя!", "x");
if (db.saveFileProperties(vault + "/a.md", bad).isEmpty())
return fail("props bad key");
}
// --- MdParser: ~sql с базой → таблица ---
{
MdParser p;
p.setDatabaseForVault(vault, &db);
const QVariantList blocks = p.parseBlocks(
"Текст\n\n```~sql\nSELECT title FROM files ORDER BY title\n```\n", vault, vault);
if (blocks.size() != 2)
return fail("sql blocks", QString::number(blocks.size()));
if (blocks[1].toMap().value("type").toString() != "html")
return fail("sql type", blocks[1].toMap().value("type").toString());
const QString html = blocks[1].toMap().value("html").toString();
// title = имя файла без расширения; пузырь кодоблока; имена — ссылки
if (!html.contains("<table") || !html.contains(">a</a>") || !html.contains(">b</a>"))
return fail("sql table", html.left(300));
if (!html.contains("#2e4d6a"))
return fail("sql bubble", html.left(200));
// plain render тоже таблицей
const QString h = p.render("```~sql\nSELECT title FROM files\n```\n", vault, vault);
if (!h.contains("<table"))
return fail("sql render", h.left(300));
// ошибка запроса → красный бокс, не падение
const QVariantList be = p.parseBlocks("```~sql\nSELECT * FROM нет_таблицы\n```\n", vault, vault);
if (!be[0].toMap().value("html").toString().contains("SQL:"))
return fail("sql errbox");
}
// --- MdParser: ~sql без базы → исходник как код ---
{
MdParser p;
const QVariantList blocks = p.parseBlocks("```~sql\nSELECT 1\n```\n", vault, vault);
if (blocks.size() != 1 || blocks[0].toMap().value("type").toString() != "code")
return fail("sql fallback");
}
// --- MdParser: шапка не светится в просмотре ---
{
MdParser p;
const QString h = p.render("---\nстатус: секрет\n---\n\n# Заголовок\n", vault, vault);
if (h.contains("секрет") || !h.contains("Заголовок"))
return fail("fm hidden", h.left(200));
}
// --- Свойства в файл без шапки: тело сохраняется ---
{
writeFile(vault + "/plain.md", "# Обычная\n\nТекст без шапки\n");
index.rebuild(vault);
db.reindex();
QVariantMap np;
np.insert("k", "v");
if (!db.saveFileProperties(vault + "/plain.md", np).isEmpty())
return fail("plain props save");
QFile f(vault + "/plain.md");
f.open(QIODevice::ReadOnly);
const QString t = QString::fromUtf8(f.readAll());
if (!t.contains("Текст без шапки") || !t.contains("k: v"))
return fail("plain body kept", t.left(200));
}
// --- Связность: вызовы из одного файла в другие через базу ---
// Граф: index -> a, index -> b, a -> b, c — изолят.
QTemporaryDir dir2;
if (!dir2.isValid())
return fail("temp dir2");
const QString v2 = dir2.path();
writeFile(v2 + "/index.md",
"---\nтип: хаб\n---\n\n# Хаб\n\n[[a]] [[b]]\n\n"
"```~sql\n"
"SELECT f2.title AS связанные FROM files f1\n"
" JOIN links l ON l.src_id = f1.id\n"
" JOIN files f2 ON f2.id = l.dst_id\n"
"WHERE f1.path LIKE '%index.md' ORDER BY f2.title;\n"
"```\n");
writeFile(v2 + "/a.md", "# A #общее\n\n[[b]]\n");
writeFile(v2 + "/b.md", "# B #общее\n");
writeFile(v2 + "/c.md", "# C\n\nИзолят\n");
VaultIndex index2;
index2.rebuild(v2);
if (index2.fileCount() != 4)
return fail("link index count", QString::number(index2.fileCount()));
Database db2;
db2.setIndex(&index2);
if (!db2.open(v2))
return fail("link db open");
QVariantMap st2 = db2.stats();
if (st2.value("files").toInt() != 4)
return fail("link files");
if (st2.value("links").toInt() != 3) // index->a, index->b, a->b
return fail("link edges", QString::number(st2.value("links").toInt()));
if (st2.value("tags").toInt() != 1)
return fail("link tags");
auto titles = [&](const QVariantMap &r) {
QStringList out;
for (const QVariant &row : r.value("rows").toList())
out.append(row.toList().value(0).toString());
return out;
};
// Прямые соседи index.md
{
QVariantMap r = db2.queryTable(
"SELECT f2.title FROM files f1 JOIN links l ON l.src_id = f1.id "
"JOIN files f2 ON f2.id = l.dst_id "
"WHERE f1.path LIKE '%index.md' ORDER BY f2.title");
if (!r.value("error").toString().isEmpty())
return fail("fwd err", r.value("error").toString());
if (titles(r) != QStringList({"a", "b"}))
return fail("fwd index", titles(r).join(","));
}
// Обратные ссылки на b.md (кто ссылается)
{
QVariantMap r = db2.queryTable(
"SELECT f.title FROM files f JOIN links l ON l.src_id = f.id "
"JOIN files d ON d.id = l.dst_id "
"WHERE d.path LIKE '%b.md' ORDER BY f.title");
if (titles(r) != QStringList({"a", "index"}))
return fail("backlinks b", titles(r).join(","));
}
// Достижимость из index.md за любое число шагов (рекурсия) — c.md вне
{
QVariantMap r = db2.queryTable(
"WITH RECURSIVE reach(id) AS ("
" SELECT id FROM files WHERE path LIKE '%index.md'"
" UNION"
" SELECT l.dst_id FROM links l JOIN reach r ON l.src_id = r.id)"
" SELECT f.title FROM files f JOIN reach r ON r.id = f.id"
" WHERE f.path NOT LIKE '%index.md' ORDER BY f.title");
if (!r.value("error").toString().isEmpty())
return fail("reach err", r.value("error").toString());
if (titles(r) != QStringList({"a", "b"}))
return fail("reach index", titles(r).join(","));
}
// Изолят: у c.md нет рёбер ни в одну сторону
{
QVariantMap r = db2.queryTable(
"SELECT COUNT(*) FROM links l JOIN files f ON f.id IN (l.src_id, l.dst_id)"
" WHERE f.path LIKE '%c.md'");
if (r.value("rows").toList().value(0).toList().value(0).toString() != "0")
return fail("isolate c");
}
// filePathForTitle: точное имя → путь, нет/несколько → пусто
{
if (db2.filePathForTitle("a") != v2 + "/a.md")
return fail("title lookup", db2.filePathForTitle("a"));
if (!db2.filePathForTitle("нет-такого").isEmpty())
return fail("title missing");
}
// ~sql внутри index.md видит другие файлы (и не видит изолят).
// Имена — ссылки flora://open, пути — flora://copycode (клик = в буфер).
{
MdParser p;
p.setDatabaseForVault(v2, &db2);
const QVariantList blocks = p.parseBlocks(
"```~sql\nSELECT title, path FROM files WHERE title IN ('a','b','c') ORDER BY title\n```\n",
v2, v2);
if (blocks.size() != 1 || blocks[0].toMap().value("type").toString() != "html")
return fail("xfile type");
const QString h = blocks[0].toMap().value("html").toString();
if (!h.contains("#2e4d6a"))
return fail("xfile bubble", h.left(200));
// имя a открывает файл
const QString openA = "flora://open/" + QString::fromLatin1(QUrl::toPercentEncoding(v2 + "/a.md"));
if (!h.contains(openA))
return fail("xfile open link", h.left(500));
// путь a копируется (spath_* зарегистрирован)
if (!h.contains("flora://copycode/spath_"))
return fail("xfile copy link", h.left(500));
if (p.getCodeBlock("spath_0") != v2 + "/a.md"
&& p.getCodeBlock("spath_1") != v2 + "/a.md"
&& p.getCodeBlock("spath_2") != v2 + "/a.md")
return fail("xfile copy target");
// изолят c тоже в выдаче (запрос его выбрал), но имя так же кликабельно
if (!h.contains(">c</a>"))
return fail("xfile c link", h.left(500));
// title без path в строке резолвится через базу
const QVariantList b2 = p.parseBlocks("```~sql\nSELECT title FROM files WHERE title='b'\n```\n", v2, v2);
const QString h2 = b2[0].toMap().value("html").toString();
const QString openB = "flora://open/" + QString::fromLatin1(QUrl::toPercentEncoding(v2 + "/b.md"));
if (!h2.contains(openB))
return fail("xfile title lookup", h2.left(500));
}
// Рендер index.md целиком: таблица есть, изолята нет, шапки нет
{
MdParser p;
p.setDatabaseForVault(v2, &db2);
QFile f(v2 + "/index.md");
f.open(QIODevice::ReadOnly);
const QString h = p.render(QString::fromUtf8(f.readAll()), v2, v2);
if (!h.contains(">a</a>") || !h.contains(">b</a>"))
return fail("xfile table", h.left(300));
if (h.contains(">c</td>") || h.contains(">c</a>"))
return fail("xfile leak isolate");
if (h.contains("тип: хаб"))
return fail("xfile fm leak");
}
// Межфайловые свойства: статусы соседей index.md
{
QVariantMap pa;
pa.insert("статус", "в работе");
if (!db2.saveFileProperties(v2 + "/a.md", pa).isEmpty())
return fail("xfile props save");
index2.updateFile(v2 + "/a.md");
db2.syncFile(v2 + "/a.md");
QVariantMap r = db2.queryTable(
"SELECT f2.title, p.value FROM files f1 JOIN links l ON l.src_id = f1.id "
"JOIN files f2 ON f2.id = l.dst_id "
"JOIN properties p ON p.file_id = f2.id "
"WHERE f1.path LIKE '%index.md' AND p.key = 'статус'");
const QVariantList rows = r.value("rows").toList();
if (rows.size() != 1 || rows[0].toList().value(0).toString() != "a"
|| rows[0].toList().value(1).toString() != "в работе")
return fail("xfile props query", r.value("error").toString());
}
// Динамика: a.md ссылается на c.md — изолят подключается
{
writeFile(v2 + "/a.md", "# A #общее\n\n[[b]] [[c]]\n");
index2.updateFile(v2 + "/a.md");
db2.syncFile(v2 + "/a.md");
if (db2.stats().value("links").toInt() != 4)
return fail("dyn link+");
QVariantMap r = db2.queryTable(
"SELECT f.title FROM files f JOIN links l ON l.src_id = f.id "
"JOIN files d ON d.id = l.dst_id "
"WHERE d.path LIKE '%c.md'");
if (titles(r) != QStringList({"a"}))
return fail("dyn backlinks c", titles(r).join(","));
}
// Динамика: удаление c.md чистит рёбра
{
QFile::remove(v2 + "/c.md");
index2.removeFile(v2 + "/c.md");
db2.syncRemove(v2 + "/c.md");
QVariantMap s = db2.stats();
if (s.value("files").toInt() != 3 || s.value("links").toInt() != 3)
return fail("dyn remove",
QString("files=%1 links=%2").arg(s.value("files").toInt())
.arg(s.value("links").toInt()));
}
std::printf("db_test OK\n");
return 0;
}