Files
Flora/tests/db_test.cpp
T
grendervill f6e1814945 Тесты связности базы + фикс syncFile (не рвать входящие рёбра)
- db_test: граф index->a, index->b, a->b + изолят c; прямые/обратные
  ссылки, достижимость через WITH RECURSIVE, ~sql из одного файла
  в другие, межфайловые свойства, подключение изолята, удаление
- Database::clearFileData: syncFile больше не удаляет строку files
  и входящие links (id сохраняется, граф целый)
2026-09-07 16:36:24 +03:00

380 lines
17 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 <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</td>") || !html.contains(">b</td>"))
return fail("sql table", html.left(300));
// 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");
}
// ~sql внутри 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</td>") || !h.contains(">b</td>"))
return fail("xfile table", h.left(300));
if (h.contains(">c</td>"))
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;
}