2026-09-07 16:30:06 +03:00
|
|
|
|
// Тест SQLite-слоя: Frontmatter, VaultSettings, Database (индекс, запросы,
|
|
|
|
|
|
// свойства) и ~sql-блоки в MdParser (таблица / fallback / ошибка).
|
|
|
|
|
|
#include <QCoreApplication>
|
|
|
|
|
|
#include <QTemporaryDir>
|
|
|
|
|
|
#include <QDir>
|
|
|
|
|
|
#include <QFile>
|
2026-09-07 17:03:07 +03:00
|
|
|
|
#include <QUrl>
|
2026-09-07 16:30:06 +03:00
|
|
|
|
#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();
|
2026-09-07 17:03:07 +03:00
|
|
|
|
// title = имя файла без расширения; пузырь кодоблока; имена — ссылки
|
|
|
|
|
|
if (!html.contains("<table") || !html.contains(">a</a>") || !html.contains(">b</a>"))
|
2026-09-07 16:30:06 +03:00
|
|
|
|
return fail("sql table", html.left(300));
|
2026-09-07 17:03:07 +03:00
|
|
|
|
if (!html.contains("#2e4d6a"))
|
|
|
|
|
|
return fail("sql bubble", html.left(200));
|
2026-09-07 16:30:06 +03:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-07 16:36:24 +03:00
|
|
|
|
// --- Связность: вызовы из одного файла в другие через базу ---
|
|
|
|
|
|
// Граф: 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");
|
|
|
|
|
|
}
|
2026-09-07 17:03:07 +03:00
|
|
|
|
// 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 целиком: таблица есть, изолята нет, шапки нет
|
2026-09-07 16:36:24 +03:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
2026-09-07 17:03:07 +03:00
|
|
|
|
if (!h.contains(">a</a>") || !h.contains(">b</a>"))
|
2026-09-07 16:36:24 +03:00
|
|
|
|
return fail("xfile table", h.left(300));
|
2026-09-07 17:03:07 +03:00
|
|
|
|
if (h.contains(">c</td>") || h.contains(">c</a>"))
|
2026-09-07 16:36:24 +03:00
|
|
|
|
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()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-07 16:30:06 +03:00
|
|
|
|
std::printf("db_test OK\n");
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|