// Тест SQLite-слоя: Frontmatter, VaultSettings, Database (индекс, запросы, // свойства) и ~sql-блоки в MdParser (таблица / fallback / ошибка). #include #include #include #include #include #include #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("a") || !html.contains(">b")) 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(" 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")) 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") || !h.contains(">b")) return fail("xfile table", h.left(300)); if (h.contains(">c") || h.contains(">c")) 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; }