Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
82 kB · 1964 lines
C++
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965// Phase 1 model tests: AppModel behavior. Split/close/keep-only/swap// flows with their invariant cascades, ActivePath repoint rules,// path-index audits after every op, workspace kind reseat, the// never-zero invariant, and the legacy session round-trip (the// pre-refactor fixture, byte-identical modulo additive id fields).//// Tests save/load sessions through SessionStore, which resolves// ${XDG_CONFIG_HOME:-~/.config}/xenomorphic/session.json. init() points// XDG_CONFIG_HOME at a fresh temp dir per test so tests are isolated// and never touch the user's real session.
#include <QtTest/QtTest>
#include "AppModel.h"#include "SessionStore.h"#include "SplitTree.h"
#include <QDir>#include <QFile>#include <QJsonArray>#include <QJsonDocument>#include <QJsonObject>#include <QSignalSpy>#include <QTemporaryDir>
#include <algorithm>#include <functional>#include <ranges>
class TestAppModel : public QObject { Q_OBJECT
// The id-keyed path index must match a fresh walk of every tree: pane // entries AND container entries. void auditIndex() const; // Write `fixture` as session.json in the temp config dir, then boot a // fresh AppModel whose SessionStore::load() reads it. void bootModelFromFixture(const QByteArray& fixture);
std::unique_ptr<AppModel> m_model; QTemporaryDir m_configDir;
private slots: void init(); void cleanup();
// Invariants + mutators. void splitInsertsSibling(); void splitBeforeKeepsFiftyFifty(); // NOLINT(readability-convert-member-functions-to-static) void splitHalvesFocusedPaneShare(); // NOLINT(readability-convert-member-functions-to-static) void splitWrapWhenAxisDiffers(); void closePaneCollapsesContainers(); void closeMatrixEdgeMiddleNestedLast(); void closeCollapseEmitsRemovedAndNormalizes(); void closeLastPaneCascadesTabThenWorkspace(); void keepOnlyPaneCollapsesTree(); void swapPanesExchangesLeaves(); void movePaneBesideReordersSameAxis(); // NOLINT(readability-convert-member-functions-to-static) void movePaneBesideWrapsOnAxisChange(); void movePaneBesideAcrossContainersCollapsesSource(); void movePaneBesideOnlyPaneIsNoop(); // NOLINT(readability-convert-member-functions-to-static) void movePaneBesideFocusesDroppedPane(); // Mission-control cross-tab/cross-workspace drops. void movePaneBesideCrossTabMovesPaneAndRecords(); void movePaneBesideCrossTabEmptiedSourceClosesTab(); void movePaneBesideLastTabOnlyPaneIsNoop(); void movePaneBesideCrossWorkspace(); void swapPanesAcrossTabsTradesContent(); void swapPanesAcrossWorkspaces(); void movePaneToTabAppendsLast(); void movePaneToNewTabRehomesPane(); void movePaneToNewTabCrossWorkspaceGuard(); void crossTabMovePersistsInSession(); // Path index audit (pane AND container entries) after every op. void pathIndexAuditAfterOps(); // ActivePath repoint rules. void selectRestoresLastActivePane(); void closeFocusedPaneRepointsToSibling(); // Workspace lifecycle. void workspaceKindReseat(); void reseatPreservesRepoCapabilities(); void setWorkspaceRepoCapabilitiesEmitsOnChange(); void neverZeroInvariant(); // Session io. void legacySessionRoundTrip(); // Startup validation: corrupted trees (bare-leaf root, zero-children // node, zero/negative splits) load to a valid, usable single-pane tree. void corruptedSessionLoadsToValidDefaults(); // Session-restore title round trip: the leaf "title" reaches // paneSpawnSpec under the "title" key (defect 2 regression). void titleRoundTripFromSession(); // setPaneRestart records on the pane record and reaches collectSession; // unknown paneId is a no-op. void setPaneRestartRecordsAndSerializes(); // NOLINT(readability-convert-member-functions-to-static) // buildSessionRoot(true) with no pool attached is a safe no-op that // still produces a valid session (the quit path cannot crash). void buildSessionRootScanQuitWithoutPool(); // NOLINT(readability-convert-member-functions-to-static) // A legacy session with no leaf "title" loads to empty spawnTitle. void legacySessionWithoutTitleLoads(); // NOLINT(readability-convert-member-functions-to-static) // equalizeSplits: a deliberately lopsided nested tree becomes equal at // every container (normalized, sum 1); a single-pane tab is a no-op. void equalizeSplitsBalancesNestedTree(); void equalizeSplitsSinglePaneNoop(); // NOLINT(readability-convert-member-functions-to-static) // Pane zoom: per-tab transient render state on XenoTab. Zooming must // leave the tree, ratios, and ActivePath byte-identical; clearing // restores them; a single-pane tab zooms to itself without corrupting // the ActivePath; zoom never leaks across tabs. void zoomPreservesTreeRatiosAndActivePath(); // Zoom pins focus: a zoomed tab renders one pane alone, so a focus // request aimed at a hidden sibling lands on the zoomed pane, and // splitting the zoomed pane ends the zoom (the fresh sibling is what // the zoom hid, and the split focuses it). void zoomPinsFocusAndSplitEndsZoom(); // Maximize collapses the chrome (sidebar via settings, tab strip via // the model flag) and restore puts back ONLY what maximize changed: // an already-collapsed sidebar stays collapsed after restore. void maximizeChromeRestore(); // Tab title override: while set, the displayed title stays the override // even as the reported title is re-pushed; clearing restores the CURRENT // reported title, not a stale one. Renaming changes no order/focus. void tabTitleOverrideShieldsReportedTitle(); // Tab reorder: moving a tab preserves the tab OBJECTS (a move, not a // rebuild), keeps the active tab the SAME TAB (id unchanged, index // follows), and end moves are no-ops. void moveTabReordersAndKeepsActiveTab(); // Pane kind: session round trip, the editor-with-no-file invariant, // keep-only survival, split inheritance, and the kind reads. void paneKindRoundTrip(); void editorLeafWithoutPathRestoresAsPicker(); void keepOnlyPaneKeepsKinds(); void splitPaneInheritsTheSourceKind(); void splitPaneWithKindReportsThePaneId(); void editorPaneShowingPathScopesToOneTab(); void dirtyEditorPanesFollowsTheNote(); void setPaneKindClearsTheOldPayload();};
// The fixture: a legacy (pre-id-fields) session matching// /tmp/xeno-session-fixture-pre.json — outer axis "v" with splits// [-1,-1], an "h" node of two leaves plus one more leaf, one// directory workspace, activeWorkspace by name, lastPaneIndex 0.void TestAppModel::init(){ qputenv("XDG_CONFIG_HOME", m_configDir.path().toUtf8()); QVERIFY(m_configDir.isValid()); m_model = std::make_unique<AppModel>(); m_model->startup({}, QString(), QStringLiteral("/tmp/xeno-test")); auditIndex();}
void TestAppModel::cleanup(){ m_model.reset();}
// The fixture: a legacy (pre-id-fields) session matching// /tmp/xeno-session-fixture-pre.json — outer axis "v" with splits// [-1,-1], an "h" node of two leaves plus one more leaf, one// directory workspace, activeWorkspace by name, lastPaneIndex 0.static const char* kLegacySession = R"({ "activeWorkspace": "workspace", "workspaces": [ { "activeTab": 0, "jjWorkspaceName": "", "kind": "directory", "name": "workspace", "path": "", "repoRoot": "", "tabs": [ { "active": true, "lastPaneIndex": 0, "title": "", "tree": { "axis": "v", "children": [ { "axis": "h", "children": [ {"command": "", "cwd": "", "cwdRel": "", "restart": false, "restartArgv": [], "title": "", "type": "leaf"}, {"command": "", "cwd": "", "cwdRel": "", "restart": false, "restartArgv": [], "title": "", "type": "leaf"} ], "splits": [-1, -1], "type": "node" }, {"command": "", "cwd": "", "cwdRel": "", "restart": false, "restartArgv": [], "title": "", "type": "leaf"} ], "splits": [-1, -1], "type": "node" } } ] } ]})";
// A session whose one tab tree is a row of an editor leaf that names a// file, next to a picker leaf.static QByteArray kindFixture(){ QJsonObject editorLeaf; editorLeaf.insert(QStringLiteral("type"), QStringLiteral("leaf")); editorLeaf.insert(QStringLiteral("paneKind"), QStringLiteral("editor")); editorLeaf.insert(QStringLiteral("editorPath"), QStringLiteral("/tmp/kind.txt")); QJsonObject pickerLeaf; pickerLeaf.insert(QStringLiteral("type"), QStringLiteral("leaf")); pickerLeaf.insert(QStringLiteral("paneKind"), QStringLiteral("picker")); QJsonArray children; children.append(editorLeaf); children.append(pickerLeaf); QJsonObject tree; tree.insert(QStringLiteral("type"), QStringLiteral("node")); tree.insert(QStringLiteral("axis"), QStringLiteral("v")); tree.insert(QStringLiteral("children"), children); tree.insert(QStringLiteral("splits"), QJsonArray{-1, -1}); QJsonObject tab; tab.insert(QStringLiteral("active"), true); tab.insert(QStringLiteral("tree"), tree); QJsonObject workspace; workspace.insert(QStringLiteral("kind"), QStringLiteral("directory")); workspace.insert(QStringLiteral("name"), QStringLiteral("workspace")); workspace.insert(QStringLiteral("path"), QString()); QJsonArray tabs; tabs.append(tab); workspace.insert(QStringLiteral("tabs"), tabs); QJsonObject root; root.insert(QStringLiteral("activeWorkspace"), QStringLiteral("workspace")); QJsonArray workspaces; workspaces.append(workspace); root.insert(QStringLiteral("workspaces"), workspaces); return QJsonDocument(root).toJson(QJsonDocument::Compact);}
// A session whose one tab tree is a bare editor leaf that names no file.static QByteArray editorLeafWithoutPathFixture(){ QJsonObject leaf; leaf.insert(QStringLiteral("type"), QStringLiteral("leaf")); leaf.insert(QStringLiteral("paneKind"), QStringLiteral("editor")); QJsonObject tab; tab.insert(QStringLiteral("active"), true); tab.insert(QStringLiteral("tree"), leaf); QJsonObject workspace; workspace.insert(QStringLiteral("kind"), QStringLiteral("directory")); workspace.insert(QStringLiteral("name"), QStringLiteral("workspace")); workspace.insert(QStringLiteral("path"), QString()); QJsonArray tabs; tabs.append(tab); workspace.insert(QStringLiteral("tabs"), tabs); QJsonObject root; root.insert(QStringLiteral("activeWorkspace"), QStringLiteral("workspace")); QJsonArray workspaces; workspaces.append(workspace); root.insert(QStringLiteral("workspaces"), workspaces); return QJsonDocument(root).toJson(QJsonDocument::Compact);}
// The pane node with `paneId` inside `tree`, or nullptr.static const PaneNode* paneNodeIn(const SplitContainerNode& tree, const QString& paneId){ const PaneNode* found = nullptr; walk(tree, [&](const auto& typed) { using T = std::decay_t<decltype(typed)>; if constexpr (std::is_same_v<T, PaneNode>) { if (found == nullptr && typed.paneId() == paneId) { found = &typed; } } }); return found;}
// The collected-session leaf map for `paneId` inside `node`, or an empty// map when the leaf is absent.static QVariantMap sessionLeafIn(const QVariantMap& node, const QString& paneId){ if (node.value(QLatin1String("type")).toString() == QLatin1String("leaf")) { return node.value(QLatin1String("paneId")).toString() == paneId ? node : QVariantMap(); } const QVariantList children = node.value(QLatin1String("children")).toList(); for (const QVariant& child : children) { const QVariantMap hit = sessionLeafIn(child.toMap(), paneId); if (!hit.isEmpty()) { return hit; } } return {};}
// The tree map of workspace 0, tab `tabIndex` in a collected session.static QVariantMap tabTreeInSession(const QVariantMap& session, int tabIndex){ const QVariantMap workspace = session.value(QLatin1String("workspaces")).toList().first().toMap(); const QVariantMap tab = workspace.value(QLatin1String("tabs")).toList().at(tabIndex).toMap(); return tab.value(QLatin1String("tree")).toMap();}
void TestAppModel::auditIndex() const{ const int workspaceCount = static_cast<int>(m_model->workspaces().size()); for (const int w : std::views::iota(0, workspaceCount)) { SplitContainerNode* tree = m_model->tabTree(w, 0); if (tree == nullptr) { continue; } walk(*tree, [&](const auto& typed) { using T = std::decay_t<decltype(typed)>; if constexpr (std::is_same_v<T, SplitContainerNode>) { QVERIFY2(m_model->pathIndex().contains(typed.containerId()), "container entry missing from index"); } else { QVERIFY2(m_model->pathIndex().contains(typed.paneId()), "pane entry missing from index"); } }); }}
void TestAppModel::splitInsertsSibling(){ const QStringList before = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QCOMPARE(static_cast<int>(before.size()), 1); const QString& pane = before.first();
m_model->splitPane(pane, SplitDirection::Right);
auto* container = m_model->tabTree(0, 0); QVERIFY(container != nullptr); QCOMPARE(container->childCount(), 2); // Boot pane (ratio 1.0) split side-by-side: exactly 50/50. QCOMPARE(container->ratios().at(0), 0.5); QCOMPARE(container->ratios().at(1), 0.5); // All measured, and the ratios sum to 1. double sum = 0.0; for (const qreal ratio : container->ratios()) { QVERIFY(ratio >= 0.0); sum += ratio; } QVERIFY(qAbs(sum - 1.0) < 1e-6); auditIndex();}
void TestAppModel::splitBeforeKeepsFiftyFifty() // NOLINT(readability-convert-member-functions-to-static){ // Regression for the 0.5/1.0 boot-split arithmetic: splitting BEFORE a // pane (Left/Up, same axis) must still yield 50/50. The old bug wrote // the halved ratio to the shifted FRESH pane's slot and left the old // pane at its full ratio (insertChild moves the old pane to index+1). const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Left); auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 2); QCOMPARE(root->ratios().at(0), 0.5); QCOMPARE(root->ratios().at(1), 0.5); auditIndex();}
void TestAppModel::splitHalvesFocusedPaneShare() // NOLINT(readability-convert-member-functions-to-static){ // Build a 3-pane container [0.5, 0.5], then split pane 0 again: its old // share (0.5) is halved between it and the fresh pane; the untouched // sibling keeps 0.5; all measured ratios sum to 1. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); m_model->splitPane(pane0, SplitDirection::Right); auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 3); QCOMPARE(root->ratios().at(0), 0.25); QCOMPARE(root->ratios().at(1), 0.25); QCOMPARE(root->ratios().at(2), 0.5); double sum = 0.0; for (const qreal ratio : root->ratios()) { QVERIFY(ratio >= 0.0); sum += ratio; } QVERIFY(qAbs(sum - 1.0) < 1e-6); auditIndex();}
void TestAppModel::splitWrapWhenAxisDiffers(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); // Root is Horizontal ("v"-side-by-side): splitting Down wraps the pane // in a fresh Vertical container. m_model->splitPane(pane0, SplitDirection::Down); SplitContainerNode* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 1); auto* wrapper = root->childAt(0)->asContainer(); QVERIFY(wrapper != nullptr); QCOMPARE(wrapper->axis(), static_cast<int>(SplitAxis::Vertical)); QCOMPARE(wrapper->childCount(), 2); // The wrapper inherits the old pane's ratio slot (1.0 for the boot // pane); inside it the pane and fresh sibling split 50/50. QCOMPARE(root->ratios().constFirst(), 1.0); QCOMPARE(wrapper->ratios().at(0), 0.5); QCOMPARE(wrapper->ratios().at(1), 0.5); auditIndex();}
void TestAppModel::closePaneCollapsesContainers(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); SplitContainerNode* tree = m_model->tabTree(0, 0); auto* root = tree; QCOMPARE(root->childCount(), 2);
// Close the fresh second pane: the tab survives with pane0 (the tab // auto-closes only when its LAST pane dies). const QStringList after = paneIdsInLayoutOrder(*tree); QCOMPARE(static_cast<int>(after.size()), 2); m_model->closePane(after.at(1)); QCOMPARE(static_cast<int>(m_model->workspaces().size()), 1); QCOMPARE(static_cast<int>(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)) .size()), 1); auditIndex();}
void TestAppModel::closeMatrixEdgeMiddleNestedLast(){ // The user-reported close matrix: edge pane, middle pane, nested // singleton collapse, down to one. After EVERY step: the tree matches // the expected shape (no singleton containers — every container holds // >=2 children), ratios stay normalized, and the path index stays // consistent. // Build: root{ C_v{ P_a, P_b, P_c }, P_d }. Right first (the boot pane // slots beside the fresh pane in the root), then two Downs inside // pane_a's half (axis wrap -> a vertical container). const QString paneA = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(paneA, SplitDirection::Right); m_model->splitPane(paneA, SplitDirection::Down); m_model->splitPane(paneA, SplitDirection::Down); const QStringList four = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QCOMPARE(static_cast<int>(four.size()), 4);
auto checkShape = [&](int expectedPanes, int expectedRootKids) { auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), expectedRootKids); QCOMPARE(static_cast<int>(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)) .size()), expectedPanes); // Every NON-ROOT container holds >=2 children (the tab root may hold // a single child — the boot/reset shape); ratios normalized. std::function<void(const SplitNode&, bool)> audit = [&](const SplitNode& node, bool isRoot) { const auto* container = node.asContainer(); if (container == nullptr) { return; } QVERIFY2(isRoot || container->childCount() >= 2, "singleton container survived a close"); double sum = 0.0; for (const qreal ratio : container->ratios()) { QVERIFY(ratio >= 0.0); sum += ratio; } QVERIFY2(qAbs(sum - 1.0) < 1e-6, "ratios not normalized"); for (const SplitNode* child : container->children()) { audit(*child, false); } }; audit(*m_model->tabTree(0, 0), true); auditIndex(); };
// 1. MIDDLE pane of the vertical container (index 1 of the four: P_b): // the container renormalizes its remaining two shares; the root is // untouched. m_model->closePane(four.at(1)); checkShape(3, 2); auto* root = m_model->tabTree(0, 0); auto* vertical = root->childAt(0)->asContainer(); QVERIFY(vertical != nullptr); QCOMPARE(vertical->childCount(), 2); QVERIFY(qAbs(vertical->ratios().at(0) + vertical->ratios().at(1) - 1.0) < 1e-6);
// 2. EDGE pane (first of the vertical container, P_a): the container // drops to one child and the cascade promotes that child (P_c) into // the root immediately — root{ P_c, P_d }. m_model->closePane(four.at(0)); checkShape(2, 2); root = m_model->tabTree(0, 0); QVERIFY(root->childAt(0)->asPane() != nullptr); QVERIFY(root->childAt(1)->asPane() != nullptr);
// 3. Last-pane-of-the-pair: closing P_c leaves P_d alone in the root // (the boot shape); focus follows the absorbing sibling. m_model->closePane(four.at(2)); checkShape(1, 1); // Focus followed the absorbing sibling. QCOMPARE(m_model->focusedPaneId(), four.at(3));}
void TestAppModel::closeCollapseEmitsRemovedAndNormalizes(){ // The collapse cascade must actually REMOVE the doomed container (the // old bug moved the container back into its own slot: it survived as a // singleton). The diff-primitive signal era is gone; the observable // contract is the tree shape + the path index. // root{ C_v{ P_a, P_b }, P_c } — closing P_a collapses C_v. const QString paneA = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(paneA, SplitDirection::Down); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); auto* rootBefore = m_model->tabTree(0, 0); QVERIFY(rootBefore != nullptr); const QString doomedContainerId = rootBefore->childAt(0)->asContainer()->containerId();
m_model->closePane(ids.at(0));
// The doomed container id must be GONE from the tree and the index. std::function<bool(const SplitNode&)> stillThere = [&](const SplitNode& node) { const auto* container = node.asContainer(); if (container == nullptr) { return false; } if (container->containerId() == doomedContainerId) { return true; } return std::ranges::any_of( container->children(), [&stillThere](const SplitNode* child) { return stillThere(*child); }); }; QVERIFY(!stillThere(*m_model->tabTree(0, 0))); QVERIFY(!m_model->pathIndex().contains(doomedContainerId)); auditIndex();}
void TestAppModel::closeLastPaneCascadesTabThenWorkspace(){ // One workspace, one tab, one pane: closing it recreates everything // (never-zero cascade). const QString pane = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->closePane(pane); QCOMPARE(static_cast<int>(m_model->workspaces().size()), 1); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QCOMPARE(static_cast<int>(ids.size()), 1); QVERIFY(!ids.first().isEmpty()); auditIndex();}
void TestAppModel::keepOnlyPaneCollapsesTree(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); m_model->splitPane(pane0, SplitDirection::Down); QCOMPARE(static_cast<int>( paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).size()), 3); m_model->keepOnlyPane(pane0); QCOMPARE(static_cast<int>(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)) .size()), 1); auditIndex();}
void TestAppModel::swapPanesExchangesLeaves(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); m_model->swapPanes(ids.at(0), ids.at(1)); auditIndex(); // Shape unchanged: still two panes in the same container. auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 2);}
void TestAppModel::movePaneBesideReordersSameAxis(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); // Move B ("left") beside A: layout order flips. m_model->movePaneBeside(ids.at(1), ids.at(0), QStringLiteral("left")); auditIndex(); QCOMPARE((paneIdsInLayoutOrder(*m_model->tabTree(0, 0))), (QStringList{ids.at(1), ids.at(0)})); // Shape still one container with 2 children, ratios sum to 1. auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 2); QCOMPARE(root->ratioAt(0) + root->ratioAt(1), 1.0);}
void TestAppModel::movePaneBesideWrapsOnAxisChange(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Down); m_model->splitPane(pane0, SplitDirection::Down); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); // Vertical tree: root{v{top, mid, bottom}}. Move the middle pane Left // beside the top -> the top's parent (still vertical, >=2 children so // no collapse) gets a fresh Horizontal wrapper holding [moved, target] // 50/50. m_model->movePaneBeside(ids.at(1), ids.at(0), QStringLiteral("left")); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), (QStringList{ids.at(1), ids.at(0), ids.at(2)})); auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); auto* vertical = root->childAt(0)->asContainer(); QVERIFY(vertical != nullptr); QCOMPARE(static_cast<int>(vertical->axis()), static_cast<int>(SplitAxis::Vertical)); auto* wrapper = vertical->childAt(0)->asContainer(); QVERIFY(wrapper != nullptr); QCOMPARE(static_cast<int>(wrapper->axis()), static_cast<int>(SplitAxis::Horizontal)); QCOMPARE(wrapper->childCount(), 2); auto* moved = wrapper->childAt(0)->asPane(); auto* target = wrapper->childAt(1)->asPane(); QVERIFY(moved != nullptr && target != nullptr); QCOMPARE(moved->paneId(), ids.at(1)); QCOMPARE(target->paneId(), ids.at(0)); QCOMPARE(wrapper->ratioAt(0) + wrapper->ratioAt(1), 1.0);}
void TestAppModel::movePaneBesideAcrossContainersCollapsesSource(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); // Tree is root{A, node{B, C}}: split B (ids.at(1)) Down. m_model->splitPane(ids.at(1), SplitDirection::Down); const QStringList three = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QCOMPARE(static_cast<int>(three.size()), 3); // Move B Right beside A: B becomes a root child, the emptied wrapper // collapses away -> root children [A, B, C]. m_model->movePaneBeside(three.at(1), three.at(0), QStringLiteral("right")); auditIndex(); auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE((paneIdsInLayoutOrder(*root)), (QStringList{three.at(0), three.at(1), three.at(2)})); // All three are direct root children now (no nested wrapper left). for (const SplitNode* child : *root) { QVERIFY(child->asPane() != nullptr); }}
void TestAppModel::movePaneBesideOnlyPaneIsNoop(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); const QString focusedBefore = m_model->focusedPaneId(); m_model->movePaneBeside(pane0, pane0, QStringLiteral("left")); auditIndex(); QCOMPARE((paneIdsInLayoutOrder(*m_model->tabTree(0, 0))), QStringList{pane0}); QCOMPARE(m_model->focusedPaneId(), focusedBefore);}
void TestAppModel::movePaneBesideFocusesDroppedPane(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); m_model->focusPane(ids.at(0)); QCOMPARE(m_model->focusedPaneId(), ids.at(0)); m_model->movePaneBeside(ids.at(1), ids.at(0), QStringLiteral("left")); QCOMPARE(m_model->focusedPaneId(), ids.at(1)); auditIndex();}
// ---------------------------------------------------------------------------// Mission-control cross-tab/cross-workspace drops.// ---------------------------------------------------------------------------
void TestAppModel::movePaneBesideCrossTabMovesPaneAndRecords(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList tab0Ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); // tab0 [A, B]; a fresh tab1 [C]. const QString& a = tab0Ids.at(0); const QString& b = tab0Ids.at(1); m_model->newTab(0); auditIndex(); const QString c = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).first(); const QString cTabId = m_model->pathIndex().value(c).tabId;
// Move B right of C: tab0 keeps [A], tab1 becomes [C, B]. m_model->movePaneBeside(b, c, QStringLiteral("right")); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{a}); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 1)), (QStringList{c, b})); QCOMPARE(m_model->paneRecord(b).tabId, cTabId); QCOMPARE(m_model->focusedPaneId(), b); // tab1's remembered pane follows the drop. QCOMPARE(m_model->pathIndex().value(b).tabId, cTabId); const auto dstWsId = m_model->pathIndex().value(b).workspaceId; const int dstTabIdx = m_model->tabIndexOf(dstWsId, cTabId); QVERIFY(dstTabIdx >= 0);}
void TestAppModel::movePaneBesideCrossTabEmptiedSourceClosesTab(){ const QString a = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->newTab(0); auditIndex(); const QString c = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).first();
// Move A (tab0's only pane) left of C: tab0 empties and auto-closes. m_model->movePaneBeside(a, c, QStringLiteral("left")); auditIndex(); QCOMPARE(static_cast<int>(m_model->workspaces().at(0)->tabs().size()), 1); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), (QStringList{a, c})); QCOMPARE(m_model->focusedPaneId(), a);}
void TestAppModel::movePaneBesideLastTabOnlyPaneIsNoop(){ const QString a = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->addWorkspace(WorkspaceKind::Directory, QStringLiteral("/tmp/xeno-test/ws2"), QStringLiteral("ws2"), QString()); auditIndex(); const QString d = paneIdsInLayoutOrder(*m_model->tabTree(1, 0)).first(); const QString focusedBefore = m_model->focusedPaneId();
// A is workspace 0's only tab's only pane: the drop would delete the // workspace, so it is a no-op. m_model->movePaneBeside(a, d, QStringLiteral("left")); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{a}); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(1, 0)), QStringList{d}); QCOMPARE(static_cast<int>(m_model->workspaces().size()), 2); QCOMPARE(m_model->focusedPaneId(), focusedBefore);}
void TestAppModel::movePaneBesideCrossWorkspace(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList tab0Ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); const QString& a = tab0Ids.at(0); const QString& b = tab0Ids.at(1); m_model->addWorkspace(WorkspaceKind::Directory, QStringLiteral("/tmp/xeno-test/ws2"), QStringLiteral("ws2"), QString()); auditIndex(); const QString d = paneIdsInLayoutOrder(*m_model->tabTree(1, 0)).first(); const QString ws1Id = m_model->pathIndex().value(d).workspaceId;
// Move B right of D in the second workspace. m_model->movePaneBeside(b, d, QStringLiteral("right")); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{a}); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(1, 0)), (QStringList{d, b})); QCOMPARE(m_model->paneRecord(b).workspaceId, ws1Id); QCOMPARE(m_model->focusedPaneId(), b);}
void TestAppModel::swapPanesAcrossTabsTradesContent(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList tab0Ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); const QString& a = tab0Ids.at(0); const QString& b = tab0Ids.at(1); m_model->newTab(0); auditIndex(); const QString c = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).first(); const QString cTabId = m_model->pathIndex().value(c).tabId; const QString aTabId = m_model->pathIndex().value(a).tabId; // Remember A in tab0. m_model->focusPane(a); QCOMPARE(m_model->focusedPaneId(), a);
// Swap A and C across the tab boundary: content and records trade. m_model->swapPanes(a, c); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), (QStringList{c, b})); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 1)), QStringList{a}); QCOMPARE(m_model->paneRecord(a).tabId, cTabId); QCOMPARE(m_model->paneRecord(c).tabId, aTabId); // tab0's remembered pane is now C (A left, C arrived). QCOMPARE(m_model->pathIndex().value(c).tabId, aTabId); QCOMPARE(m_model->pathIndex().value(a).tabId, cTabId);}
void TestAppModel::swapPanesAcrossWorkspaces(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList tab0Ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); const QString& a = tab0Ids.at(0); const QString& b = tab0Ids.at(1); m_model->addWorkspace(WorkspaceKind::Directory, QStringLiteral("/tmp/xeno-test/ws2"), QStringLiteral("ws2"), QString()); auditIndex(); const QString d = paneIdsInLayoutOrder(*m_model->tabTree(1, 0)).first(); const QString ws1Id = m_model->pathIndex().value(d).workspaceId; const QString ws0Id = m_model->pathIndex().value(a).workspaceId;
m_model->swapPanes(a, d); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), (QStringList{d, b})); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(1, 0)), QStringList{a}); QCOMPARE(m_model->paneRecord(a).workspaceId, ws1Id); QCOMPARE(m_model->paneRecord(d).workspaceId, ws0Id);}
void TestAppModel::movePaneToTabAppendsLast(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList tab0Ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); const QString& a = tab0Ids.at(0); const QString& b = tab0Ids.at(1); m_model->newTab(0); auditIndex(); const QString c = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).first(); const QString cTabId = m_model->pathIndex().value(c).tabId;
m_model->movePaneToTab(a, 0, 1); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{b}); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 1)), (QStringList{c, a})); QCOMPARE(m_model->paneRecord(a).tabId, cTabId); QCOMPARE(m_model->focusedPaneId(), a); // Root's measured ratios sum to 1. auto* dstRoot = m_model->tabTree(0, 1); QVERIFY(dstRoot != nullptr); double sum = 0.0; for (const qreal ratio : dstRoot->ratios()) { QVERIFY(ratio >= 0.0); sum += ratio; } QVERIFY(qAbs(sum - 1.0) < 1e-6);}
void TestAppModel::movePaneToNewTabRehomesPane(){ const QString a = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->newTab(0); auditIndex(); QCOMPARE(static_cast<int>(m_model->workspaces().at(0)->tabs().size()), 2);
// Move A into a brand-new third tab: tab count grows by one and the // new tab holds exactly [A] with focus on it. m_model->movePaneToNewTab(a, 0); auditIndex(); QCOMPARE(static_cast<int>(m_model->workspaces().at(0)->tabs().size()), 2); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 1)), QStringList{a}); QCOMPARE(m_model->focusedPaneId(), a);}
void TestAppModel::movePaneToNewTabCrossWorkspaceGuard(){ const QString a = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->addWorkspace(WorkspaceKind::Directory, QStringLiteral("/tmp/xeno-test/ws2"), QStringLiteral("ws2"), QString()); auditIndex(); const QString d = paneIdsInLayoutOrder(*m_model->tabTree(1, 0)).first();
// Workspace 0's only tab's only pane cannot move to workspace 1. m_model->movePaneToNewTab(a, 1); auditIndex(); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{a}); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(1, 0)), QStringList{d}); QCOMPARE(static_cast<int>(m_model->workspaces().size()), 2); QCOMPARE(static_cast<int>(m_model->workspaces().at(0)->tabs().size()), 1); QCOMPARE(static_cast<int>(m_model->workspaces().at(1)->tabs().size()), 1);}
void TestAppModel::crossTabMovePersistsInSession(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList tab0Ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); const QString& a = tab0Ids.at(0); const QString& b = tab0Ids.at(1); m_model->newTab(0); auditIndex(); const QString c = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).first();
m_model->movePaneBeside(b, c, QStringLiteral("right")); auditIndex();
// Walk the collected session: workspace 0's tab list shows B in tab1's // tree and not in tab0's. const QVariantMap session = m_model->collectSession(false); const QVariantList wsList = session.value(QLatin1String("workspaces")).toList(); QVERIFY(!wsList.isEmpty()); const QVariantList tabs = wsList.first().toMap().value(QLatin1String("tabs")).toList(); QCOMPARE(static_cast<int>(tabs.size()), 2); const auto treePaneIds = [](const QVariantMap& tree) { QStringList ids; std::function<void(const QVariantMap&)> walk = [&](const QVariantMap& node) { if (node.value(QLatin1String("type")).toString() == QLatin1String("leaf")) { ids.append(node.value(QLatin1String("paneId")).toString()); return; } const QVariantList children = node.value(QLatin1String("children")).toList(); for (const QVariant& child : children) { walk(child.toMap()); } }; walk(tree); return ids; }; const QStringList tab0Panes = treePaneIds( tabs.at(0).toMap().value(QLatin1String("tree")).toMap()); const QStringList tab1Panes = treePaneIds( tabs.at(1).toMap().value(QLatin1String("tree")).toMap()); QVERIFY(!tab0Panes.contains(b)); QVERIFY(tab1Panes.contains(b)); QVERIFY(tab1Panes.contains(c)); QVERIFY(tab0Panes.contains(a));}
void TestAppModel::pathIndexAuditAfterOps(){ // The audit ran inside init() and after each op above; this test also // walks an explicit chain of ops and audits after each. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); auditIndex(); m_model->splitPane(pane0, SplitDirection::Down); auditIndex(); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); m_model->closePane(ids.at(2)); auditIndex(); m_model->keepOnlyPane(ids.at(0)); auditIndex();}
void TestAppModel::selectRestoresLastActivePane(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); // Splitting focuses the fresh pane. QCOMPARE(m_model->focusedPaneId(), ids.at(1)); // Focus back to pane0, then select away and back: the tab remembers. m_model->focusPane(pane0); m_model->newTab(0); m_model->selectTab(0, 1); m_model->selectTab(0, 0); QCOMPARE(m_model->focusedPaneId(), pane0);}
void TestAppModel::closeFocusedPaneRepointsToSibling(){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QVERIFY(m_model->focusedPaneId() == ids.at(1)); // Close the focused pane: focus repoints to the surviving sibling. m_model->closePane(ids.at(1)); QCOMPARE(m_model->focusedPaneId(), ids.at(0)); auditIndex();}
void TestAppModel::neverZeroInvariant(){ // Deleting the only workspace recreates a default one. QCOMPARE(static_cast<int>(m_model->workspaces().size()), 1); m_model->deleteWorkspace(0); QCOMPARE(static_cast<int>(m_model->workspaces().size()), 1); auditIndex();}
void TestAppModel::workspaceKindReseat(){ // addWorkspace(jj) then forgetJjWorkspace: the workspace OBJECT is // replaced (kind is per-object now), keeping id/name/path/repoRoot and // moving the tabs over. const QString wsId = m_model->addWorkspace( WorkspaceKind::JjWorkspace, QStringLiteral("/tmp/repo/ws"), QStringLiteral("other"), QStringLiteral("/tmp/repo")); QCOMPARE(wsId.isEmpty(), false); Workspace* jj = m_model->workspaces().at(1); QCOMPARE(jj->kind(), WorkspaceKind::JjWorkspace); QCOMPARE(jj->jjWorkspaceName(), QStringLiteral("other")); QCOMPARE(jj->id(), wsId); m_model->forgetJjWorkspace(1); Workspace* repo = m_model->workspaces().at(1); QCOMPARE(repo->kind(), WorkspaceKind::Repository); QCOMPARE(repo->kindWord(), QStringLiteral("repo")); QCOMPARE(repo->repoRoot(), QStringLiteral("/tmp/repo")); QCOMPARE(repo->id(), wsId); // ids survive the replacement QCOMPARE(repo->name(), QStringLiteral("other")); QCOMPARE(repo->isJjWorkspace(), false);}
void TestAppModel::reseatPreservesRepoCapabilities(){ // reseatWorkspaceKind carries the old object's isJjRepo/isGitRepo onto // the replacement (the controller overwrites them right after via // setWorkspaceRepoCapabilities, but the replacement must never drop // them mid-flight). m_model->addWorkspace(WorkspaceKind::JjWorkspace, QStringLiteral("/tmp/repo/ws"), QStringLiteral("other"), QStringLiteral("/tmp/repo")); Workspace* jj = m_model->workspaces().at(1); jj->setJjRepo(true); jj->setGitRepo(true); const QString wsId = jj->id(); m_model->reseatWorkspaceKind(1, WorkspaceKind::Repository, QStringLiteral("/tmp/repo"), jj->jjWorkspaceName()); Workspace* repo = m_model->workspaces().at(1); QCOMPARE(repo->kind(), WorkspaceKind::Repository); QCOMPARE(repo->id(), wsId); QCOMPARE(repo->isJjRepo(), true); QCOMPARE(repo->isGitRepo(), true);
// Same-kind reseat is a no-op: same object, capabilities untouched. Workspace* before = m_model->workspaces().at(1); m_model->reseatWorkspaceKind(1, WorkspaceKind::Repository, before->repoRoot(), before->jjWorkspaceName()); QCOMPARE(m_model->workspaces().at(1), before); QCOMPARE(m_model->workspaces().at(1)->isJjRepo(), true); QCOMPARE(m_model->workspaces().at(1)->isGitRepo(), true);}
void TestAppModel::setWorkspaceRepoCapabilitiesEmitsOnChange(){ // No-op on unchanged values: no workspacesChanged. On a change: the // workspace object's flags update and workspacesChanged fires exactly // once per call. Invalid index is a silent no-op. QSignalSpy changed(m_model.get(), &AppModel::workspacesChanged); m_model->setWorkspaceRepoCapabilities(0, true, true); QCOMPARE(changed.count(), 1); QCOMPARE(m_model->workspaces().at(0)->isJjRepo(), true); QCOMPARE(m_model->workspaces().at(0)->isGitRepo(), true);
m_model->setWorkspaceRepoCapabilities(0, true, true); QCOMPARE(changed.count(), 1);
m_model->setWorkspaceRepoCapabilities(0, false, true); QCOMPARE(changed.count(), 2); QCOMPARE(m_model->workspaces().at(0)->isJjRepo(), false); QCOMPARE(m_model->workspaces().at(0)->isGitRepo(), true);
m_model->setWorkspaceRepoCapabilities(99, true, true); QCOMPARE(changed.count(), 2);}
void TestAppModel::bootModelFromFixture(const QByteArray& fixture){ const QString sessionPath = m_configDir.filePath(QStringLiteral("xenomorphic/session.json")); QVERIFY(QDir().mkpath(QFileInfo(sessionPath).absolutePath())); QFile file(sessionPath); QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); QCOMPARE(file.write(fixture), static_cast<qint64>(fixture.size())); file.close(); m_model = std::make_unique<AppModel>(); m_model->startup({}, QString(), QStringLiteral("/tmp/xeno-test")); auditIndex();}
void TestAppModel::legacySessionRoundTrip(){ // Load the pre-refactor fixture (no id fields) -> save -> the emitted // JSON keeps every legacy key with the same value and only ADDS the // additive id fields (workspace id, tab id + lastActivePaneId, leaf // paneId). bootModelFromFixture(QByteArray(kLegacySession)); const QVariantMap saved = m_model->collectSession(false); QVERIFY(!saved.isEmpty());
const QVariantMap legacyRoot = QJsonDocument::fromJson(QByteArray(kLegacySession)) .object() .toVariantMap(); const QVariantMap legacyWs = legacyRoot.value(QLatin1String("workspaces")).toList().first().toMap(); const QVariantMap savedWs = saved.value(QLatin1String("workspaces")).toList().first().toMap(); // Every legacy workspace key survives with the same value. for (auto it = legacyWs.constBegin(); it != legacyWs.constEnd(); ++it) { QVERIFY2(savedWs.contains(it.key()), qPrintable(QStringLiteral("missing key: %1").arg(it.key()))); if (it.key() != QLatin1String("tabs")) { QCOMPARE(savedWs.value(it.key()), it.value()); } } // Additive: workspace "id"; tab "id" + "lastActivePaneId"; leaf // "paneId". lastPaneIndex is still written alongside lastActivePaneId. QVERIFY(savedWs.contains(QLatin1String("id"))); const QVariantMap savedTab = savedWs.value(QLatin1String("tabs")).toList().first().toMap(); QVERIFY(savedTab.contains(QLatin1String("id"))); QVERIFY(savedTab.contains(QLatin1String("lastActivePaneId"))); QVERIFY(savedTab.contains(QLatin1String("lastPaneIndex"))); // Legacy tree keys survive on the emitted leaf. const QVariantMap savedTree = savedTab.value(QLatin1String("tree")).toMap(); QCOMPARE(savedTree.value(QLatin1String("type")).toString(), QStringLiteral("node")); QCOMPARE(savedTree.value(QLatin1String("axis")).toString(), QStringLiteral("v")); QCOMPARE(savedTree.value("splits").toList().size(), 2);}
void TestAppModel::corruptedSessionLoadsToValidDefaults(){ // User-reported boot regression: an unvalidated session (a bare-leaf // tree root, a node with zero children, and 0-ratio children) restored // trees the reconciler laid out at zero size — the terminal pane was // invisible until the first split. The loader must run the invariants: // bare-leaf roots wrapped in a container, ratios renormalized, and a // session with NO usable workspaces falls back to the default one. const char* fixture = R"({ "activeWorkspace": "broken", "workspaces": [ { "kind": "directory", "name": "broken", "path": "/tmp/broken", "tabs": [ { "active": true, "title": "", "tree": {"type": "leaf", "cwd": "", "cwdRel": ".", "command": "", "title": "", "restart": false, "restartArgv": []} } ] }, { "kind": "directory", "name": "empty-node", "path": "/tmp/empty", "tabs": [ { "active": true, "title": "", "tree": {"type": "node", "axis": "v", "children": [], "splits": []} } ] }, { "kind": "directory", "name": "zero-splits", "path": "/tmp/zero", "tabs": [ { "active": true, "title": "", "tree": {"type": "node", "axis": "v", "splits": [0, 0], "children": [ {"type": "leaf", "cwd": "", "cwdRel": ".", "command": "", "title": "", "restart": false, "restartArgv": []}, {"type": "leaf", "cwd": "", "cwdRel": ".", "command": "", "title": "", "restart": false, "restartArgv": []} ]} } ] } ]})"; bootModelFromFixture(QByteArray(fixture));
// All three workspaces survive (each tab parses to >=1 pane); every // tree is a valid container root with >=1 pane and consistent ratios. QCOMPARE(static_cast<int>(m_model->workspaces().size()), 3); for (const int w : std::views::iota(0, 3)) { SplitContainerNode* root = m_model->tabTree(w, 0); QVERIFY(root != nullptr); // The tab root is ALWAYS a container (a bare leaf gets wrapped). QVERIFY2(root != nullptr, "tab root must be a container after load"); QVERIFY(root->containerId().length() > 0); const QStringList panes = paneIdsInLayoutOrder(*root); QVERIFY2(!panes.empty(), "tab must restore >=1 pane"); // Unmeasured children (a 0/negative/absent split) are legal post-load // — QML coalesces each to an even 1/n share — but any MEASURED ratio // must be positive, and an all-measured root must sum to 1. double sum = 0.0; bool allMeasured = !root->ratios().isEmpty(); for (const qreal ratio : root->ratios()) { if (ratio < 0.0) { allMeasured = false; continue; } QVERIFY(ratio > 0.0); sum += ratio; } if (allMeasured) { QVERIFY2(qAbs(sum - 1.0) < 1e-6, "restored root ratios must sum to 1"); } // The focused pane points at a real pane of the active workspace. auditIndex(); } QVERIFY(m_model->panes().contains(m_model->focusedPaneId())); // The wrapped bare leaf (workspace 0) is splittable: the model treats // it like any normal tree afterwards. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); m_model->splitPane(pane0, SplitDirection::Right); auto* split = m_model->tabTree(0, 0); QVERIFY(split != nullptr); QCOMPARE(split->childCount(), 2); QCOMPARE(split->ratios().at(0), 0.5); auditIndex();
// A session with NO usable workspaces at all falls back to the default. bootModelFromFixture(QByteArray(R"({ "activeWorkspace": "x", "workspaces": [ {"kind": "directory", "name": "x", "path": "", "tabs": [{"active": true, "title": "", "tree": { "type": "node", "axis": "v", "children": [], "splits": []}}]} ]})")); // The empty-node-only workspace still yields one workspace (its tab // restores as a single pane), so this exercises the shape, not the // fallback. A truly EMPTY session list is the fallback path: QVERIFY(static_cast<int>(m_model->workspaces().size()) >= 1); QVERIFY(m_model->panes().contains(m_model->focusedPaneId())); bootModelFromFixture(QByteArray(R"({"activeWorkspace": "x", "workspaces": []})")); QCOMPARE(static_cast<int>(m_model->workspaces().size()), 1); QVERIFY(m_model->panes().contains(m_model->focusedPaneId())); auditIndex();}
void TestAppModel::titleRoundTripFromSession(){ // Defect 2 regression: a leaf carrying a non-empty "title" must round // trip into paneSpawnSpec under the "title" key (session restore feeds // it to the pool's spawn spec so the restored pane gets its title). bootModelFromFixture(QByteArray(R"({ "activeWorkspace": "titled", "workspaces": [ { "kind": "directory", "name": "titled", "path": "/tmp/titled", "tabs": [ { "active": true, "title": "", "tree": {"type": "leaf", "cwd": "", "cwdRel": ".", "command": "", "paneId": "pane-titled-1", "restart": false, "restartArgv": [], "title": "TITLE_PROBE_XYZ", "type": "leaf"} } ] } ]})")); QVERIFY(m_model->panes().contains(QStringLiteral("pane-titled-1"))); QCOMPARE(m_model->paneRecord(QStringLiteral("pane-titled-1")).spawnTitle, QStringLiteral("TITLE_PROBE_XYZ")); const QVariantMap spec = m_model->paneSpawnSpec(QStringLiteral("pane-titled-1")); QCOMPARE(spec.value(QLatin1String("title")).toString(), QStringLiteral("TITLE_PROBE_XYZ")); // The title also survives a save round trip. (A bare-leaf root is // wrapped in a container on load, so descend to the first leaf.) const QVariantMap saved = m_model->collectSession(false); const QVariantMap ws = saved.value(QLatin1String("workspaces")).toList().first().toMap(); const QVariantMap tab = ws.value(QLatin1String("tabs")).toList().first().toMap(); const QVariantMap tree = tab.value(QLatin1String("tree")).toMap(); const QVariantMap leaf = tree.value(QLatin1String("type")).toString() == QLatin1String("leaf") ? tree : tree.value(QLatin1String("children")).toList().first().toMap(); QCOMPARE(leaf.value(QLatin1String("title")).toString(), QStringLiteral("TITLE_PROBE_XYZ"));}
void TestAppModel::setPaneRestartRecordsAndSerializes() // NOLINT(readability-convert-member-functions-to-static){ const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst();
m_model->setPaneRestart(pane0, true, QStringList{"/bin/zsh", "-l"});
const QVariantMap spec = m_model->paneSpawnSpec(pane0); QCOMPARE(spec.value(QLatin1String("restart")).toBool(), true); QCOMPARE(spec.value(QLatin1String("restartArgv")).toStringList(), (QStringList{"/bin/zsh", "-l"})); const QVariantMap saved = m_model->collectSession(false); const QVariantMap ws = saved.value(QLatin1String("workspaces")).toList().first().toMap(); const QVariantMap tab = ws.value(QLatin1String("tabs")).toList().first().toMap(); const QVariantMap tree = tab.value(QLatin1String("tree")).toMap(); const QVariantMap leaf = tree.value(QLatin1String("type")).toString() == QLatin1String("leaf") ? tree : tree.value(QLatin1String("children")).toList().first().toMap(); QCOMPARE(leaf.value(QLatin1String("restart")).toBool(), true); QCOMPARE(leaf.value(QLatin1String("restartArgv")).toStringList(), (QStringList{"/bin/zsh", "-l"}));
// Unknown paneId: a no-op, not a crash. m_model->setPaneRestart(QStringLiteral("no-such-pane"), true, QStringList{"/bin/ls"}); QCOMPARE(m_model->paneRecord(pane0).restart, true);}
void TestAppModel::buildSessionRootScanQuitWithoutPool() // NOLINT(readability-convert-member-functions-to-static){ // No pool is attached in the tests: buildSessionRoot(true) must be a // safe no-op for the scan and still produce a valid session — proving // the quit path cannot crash when there is no pool. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); m_model->setPaneRestart(pane0, true, QStringList{"/bin/vim"}); const QVariantMap saved = m_model->collectSession(true); QVERIFY(!saved.isEmpty()); const QVariantMap ws = saved.value(QLatin1String("workspaces")).toList().first().toMap(); const QVariantMap tab = ws.value(QLatin1String("tabs")).toList().first().toMap(); const QVariantMap tree = tab.value(QLatin1String("tree")).toMap(); const QVariantMap leaf = tree.value(QLatin1String("type")).toString() == QLatin1String("leaf") ? tree : tree.value(QLatin1String("children")).toList().first().toMap(); QCOMPARE(leaf.value(QLatin1String("restart")).toBool(), true);}
void TestAppModel::legacySessionWithoutTitleLoads() // NOLINT(readability-convert-member-functions-to-static){ // The legacy fixture's leaves carry "title": "" (and older sessions may // omit the key entirely): loading must produce an empty spawnTitle with // no crash, and paneSpawnSpec's "title" stays empty. bootModelFromFixture(QByteArray(R"({ "activeWorkspace": "workspace", "workspaces": [ { "kind": "directory", "name": "workspace", "path": "", "tabs": [ { "active": true, "title": "", "tree": {"type": "leaf", "cwd": "", "cwdRel": ".", "command": "", "restart": false, "restartArgv": [], "type": "leaf"} } ] } ]})")); QVERIFY(static_cast<int>(m_model->panes().size()) >= 1); for (const auto& record : m_model->panes()) { QVERIFY(record.spawnTitle.isEmpty()); } const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); QVERIFY(m_model ->paneSpawnSpec(pane0) .value(QLatin1String("title")) .toString() .isEmpty()); auditIndex();}
void TestAppModel::equalizeSplitsBalancesNestedTree(){ // Build a nested tree: split right (root [p0,p1] horizontal), then split // p0 down (wraps p0 in a vertical container). Then skew every container // through setRatios — the user's lopsided drag state. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(pane0, SplitDirection::Right); const QStringList two = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); m_model->splitPane(two.at(0), SplitDirection::Down);
auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 2); auto* wrapper = root->childAt(0)->asContainer(); QVERIFY(wrapper != nullptr); const QString rootId = root->containerId(); const QString wrapperId = wrapper->containerId();
// Deliberately lopsided: root 0.9/0.1, wrapper 0.8/0.2. m_model->setRatios(rootId, {0.9, 0.1}); m_model->setRatios(wrapperId, {0.8, 0.2}); QCOMPARE(root->ratios().at(0), 0.9); QCOMPARE(root->ratios().at(1), 0.1); QCOMPARE(wrapper->ratios().at(0), 0.8); QCOMPARE(wrapper->ratios().at(1), 0.2);
m_model->equalizeSplits();
// Every container equal at every level: 1/2 each, normalized sums. const auto checkContainer = [](const SplitContainerNode& container) { const qsizetype n = container.ratios().size(); double sum = 0.0; for (const qreal ratio : container.ratios()) { QCOMPARE(ratio, 1.0 / static_cast<qreal>(n)); sum += ratio; } QVERIFY(qAbs(sum - 1.0) < 1e-6); }; checkContainer(*root); checkContainer(*wrapper); auditIndex();}
void TestAppModel::equalizeSplitsSinglePaneNoop() // NOLINT(readability-convert-member-functions-to-static){ // Single-pane tab: nothing to balance, and nothing observable changes — // no tree rebuild, no signal storm, the pane keeps its identity. auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 1); QSignalSpy ratiosSpy(root, &SplitContainerNode::ratiosChanged); const QString pane0 = paneIdsInLayoutOrder(*root).constFirst();
m_model->equalizeSplits();
QCOMPARE(ratiosSpy.count(), 0); QVERIFY(m_model->tabTree(0, 0) == root); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{pane0}); auditIndex();}
void TestAppModel::zoomPreservesTreeRatiosAndActivePath(){ // Build a lopsided 3-pane tree: split, then drag the first ratio off // even so a restore must reproduce non-trivial ratios. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); m_model->splitPane(pane0, SplitDirection::Right); const QString pane1 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).at(1); m_model->splitPane(pane1, SplitDirection::Down);
auto* root = m_model->tabTree(0, 0); QVERIFY(root != nullptr); QCOMPARE(root->childCount(), 2); // Lopsided ratios on the root container (0.7 / 0.3). const QString rootId = root->containerId(); m_model->setRatios(rootId, {0.7, 0.3});
// Record EVERYTHING: tree pointer, shape, ratios, layout order, // ActivePath, and the second tab's zoom (the leak guard pre-state). const auto* rootBefore = m_model->tabTree(0, 0); const QStringList orderBefore = paneIdsInLayoutOrder(*rootBefore); const QList<qreal> ratiosBefore = rootBefore->ratios(); const QString containerIdBefore = rootBefore->containerId();
// Zoom the FIRST pane (not the focused one necessarily): focus pane1 // first so zoom targets it, then record the path AFTER the intentional // focus move (zoom itself must not move focus). m_model->focusPane(pane1); QCOMPARE(m_model->focusedPaneId(), pane1); const ActivePath pathBefore = m_model->activePath(); m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), pane1);
// The tree is untouched: same OBJECT, same shape, same ratios, same // ActivePath (zoom must not move focus). QVERIFY(m_model->tabTree(0, 0) == rootBefore); QCOMPARE(rootBefore->containerId(), containerIdBefore); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), orderBefore); QCOMPARE(m_model->tabTree(0, 0)->ratios(), ratiosBefore); QVERIFY(m_model->activePath().workspaceId == pathBefore.workspaceId); QVERIFY(m_model->activePath().tabId == pathBefore.tabId); QVERIFY(m_model->activePath().paneId == pathBefore.paneId);
// Toggling again clears; the zoom reports empty and the state is still // identical. m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), QString()); QVERIFY(m_model->tabTree(0, 0) == rootBefore); QCOMPARE(m_model->tabTree(0, 0)->ratios(), ratiosBefore); QVERIFY(m_model->activePath().paneId == pathBefore.paneId);
// Maximize zooms without a prior focus move (the focused pane is still // pane1) and re-asserts focus; clearPaneZoom drops it. m_model->maximizePane(); QCOMPARE(m_model->activeZoomedPaneId(), pane1); QCOMPARE(m_model->focusedPaneId(), pane1); m_model->clearPaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), QString()); QCOMPARE(m_model->tabTree(0, 0)->ratios(), ratiosBefore); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), orderBefore);
// Single-pane tab: zoom to itself must not corrupt the ActivePath. m_model->newTab(0); const QString lonePane = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).constFirst(); m_model->focusPane(lonePane); const ActivePath lonePath = m_model->activePath(); m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), lonePane); QVERIFY(m_model->activePath().paneId == lonePath.paneId); m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), QString()); QVERIFY(m_model->activePath().paneId == lonePath.paneId);
// Per-tab isolation: zoom tab 1's pane, switch to tab 0 — tab 0 must // report NO zoom, and back to tab 1 the zoom must survive. m_model->togglePaneZoom(); // zoom tab 1's lone pane QCOMPARE(m_model->activeZoomedPaneId(), lonePane); m_model->selectTab(0, 0); QCOMPARE(m_model->activeZoomedPaneId(), QString()); m_model->selectTab(0, 1); QCOMPARE(m_model->activeZoomedPaneId(), lonePane);
// Closing the zoomed pane clears the dangling id (hygiene). m_model->closePane(lonePane); QCOMPARE(m_model->activeZoomedPaneId(), QString()); auditIndex();}
void TestAppModel::zoomPinsFocusAndSplitEndsZoom(){ // Two panes side by side: zoom must be able to hide a sibling. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); m_model->splitPane(pane0, SplitDirection::Right); const QString pane1 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).at(1);
m_model->focusPane(pane0); QCOMPARE(m_model->focusedPaneId(), pane0); m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), pane0);
// Focus requests aimed at the hidden sibling land on the zoomed pane: // direct, spatial (the arrow keys), and round-robin alike. A hidden // pane must never hold the ActivePath, or the keyboard types into a // terminal nobody can see. m_model->focusPane(pane1); QCOMPARE(m_model->focusedPaneId(), pane0); m_model->focusDirection(pane0, SplitDirection::Right); QCOMPARE(m_model->focusedPaneId(), pane0); m_model->focusNextPane(); QCOMPARE(m_model->focusedPaneId(), pane0);
// Focus is still pinned after a zoom of the OTHER pane, and unzooming // restores free movement. m_model->togglePaneZoom(); // clears (the focus is pane0) QCOMPARE(m_model->activeZoomedPaneId(), QString()); m_model->focusPane(pane1); QCOMPARE(m_model->focusedPaneId(), pane1); m_model->togglePaneZoom(); // zoom pane1 QCOMPARE(m_model->activeZoomedPaneId(), pane1); m_model->focusPane(pane0); QCOMPARE(m_model->focusedPaneId(), pane1); m_model->clearPaneZoom(); m_model->focusPane(pane0); QCOMPARE(m_model->focusedPaneId(), pane0);
// Splitting a zoomed pane ends the zoom: the fresh sibling is exactly // what the zoom hid, and the split focuses it. m_model->togglePaneZoom(); // zoom pane0 again QCOMPARE(m_model->activeZoomedPaneId(), pane0); m_model->splitPane(pane0, SplitDirection::Down); QCOMPARE(m_model->activeZoomedPaneId(), QString()); const QString fresh = m_model->focusedPaneId(); QVERIFY(fresh != pane0); QVERIFY(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).contains(fresh));
// A pane dropped inside a zoomed tab does not steal the focus either // (movePaneBeside writes the ActivePath directly, so it needs the same // pin as focusPane). const QStringList order = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); const QString& a = order.at(0); const QString& b = order.at(1); m_model->focusPane(a); m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), a); m_model->movePaneBeside(b, a, QStringLiteral("right")); QCOMPARE(m_model->activeZoomedPaneId(), a); QCOMPARE(m_model->focusedPaneId(), a); auditIndex();}
void TestAppModel::maximizeChromeRestore(){ // Two panes so zoom has something to hide. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); m_model->splitPane(pane0, SplitDirection::Right);
// Fresh model: sidebar expanded, tab strip visible. Maximize collapses // both; un-maximize (toggle again) restores both. QVERIFY(!m_model->settings()->sidebarCollapsed()); QVERIFY(!m_model->tabStripHidden()); QVERIFY(!m_model->chromeMaximized()); m_model->maximizePane(); QVERIFY(m_model->chromeMaximized()); QVERIFY(m_model->settings()->sidebarCollapsed()); QVERIFY(m_model->tabStripHidden()); m_model->maximizePane(); // the toggle un-maximizes QVERIFY(!m_model->chromeMaximized()); QVERIFY(!m_model->settings()->sidebarCollapsed()); QVERIFY(!m_model->tabStripHidden());
// A sidebar ALREADY collapsed before maximizing must stay collapsed // after restore: maximize remembers only what IT changed. m_model->settings()->setSidebarCollapsed(true); m_model->maximizePane(); QVERIFY(m_model->chromeMaximized()); QVERIFY(m_model->settings()->sidebarCollapsed()); m_model->maximizePane(); QVERIFY(!m_model->chromeMaximized()); QVERIFY(m_model->settings()->sidebarCollapsed());
// Same for the tab strip hidden manually before maximizing (only // reachable programmatically; QML hides it through this same flag). m_model->setTabStripHiddenForTest(true); m_model->maximizePane(); QVERIFY(m_model->tabStripHidden()); m_model->maximizePane(); QVERIFY(m_model->tabStripHidden()); m_model->setTabStripHiddenForTest(false);
// clearPaneZoom also ends a maximize (the pill/zoom is gone, chrome // must come back). The sidebar is STILL collapsed here (the previous // section collapsed it manually) — restore must leave it that way; // only the tab strip comes back. m_model->maximizePane(); QVERIFY(m_model->chromeMaximized()); m_model->clearPaneZoom(); QVERIFY(!m_model->chromeMaximized()); QVERIFY(m_model->settings()->sidebarCollapsed()); QVERIFY(!m_model->tabStripHidden()); m_model->settings()->setSidebarCollapsed(false); QVERIFY(!m_model->tabStripHidden());
// togglePaneZoom ends a maximize the same way clearPaneZoom // does: a maximize is a zoom plus collapsed chrome, so the zoom toggle // cannot leave the chrome hidden with every pane back on screen. m_model->maximizePane(); QVERIFY(m_model->chromeMaximized()); QVERIFY(!m_model->activeZoomedPaneId().isEmpty()); m_model->togglePaneZoom(); QCOMPARE(m_model->activeZoomedPaneId(), QString()); QVERIFY(!m_model->chromeMaximized()); QVERIFY(!m_model->settings()->sidebarCollapsed()); QVERIFY(!m_model->tabStripHidden());}
void TestAppModel::tabTitleOverrideShieldsReportedTitle(){ // Seed a reported title through the SAME path the panes use. const QString pane0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).constFirst(); m_model->focusPane(pane0); m_model->notePaneTitle(pane0, QStringLiteral("auto-title-one")); QCOMPARE(m_model->tabDisplayTitle(0, 0), QStringLiteral("auto-title-one")); XenoTab* tab = m_model->workspaces().at(0)->tabs().at(0); QCOMPARE(tab->title(), QStringLiteral("auto-title-one"));
// Set the override; the displayed title flips to it. m_model->setTabTitleOverride(0, 0, QStringLiteral(" My Tab ")); QCOMPARE(tab->titleOverride(), QStringLiteral("My Tab")); // trimmed QCOMPARE(tab->title(), QStringLiteral("My Tab"));
// Push a NEW reported title while overridden: the displayed title stays // the override, but the reported title tracked underneath. m_model->notePaneTitle(pane0, QStringLiteral("auto-title-two")); QCOMPARE(m_model->tabDisplayTitle(0, 0), QStringLiteral("auto-title-two")); QCOMPARE(tab->title(), QStringLiteral("My Tab")); QCOMPARE(tab->titleOverride(), QStringLiteral("My Tab"));
// Clear (empty) — the displayed title is the CURRENT reported title, // not the stale first one. m_model->setTabTitleOverride(0, 0, QString()); QCOMPARE(tab->titleOverride(), QString()); QCOMPARE(tab->title(), QStringLiteral("auto-title-two"));
// A rename changes no tab order, active tab, or pane state. QCOMPARE(m_model->workspaces().at(0)->tabs().size(), 1); QVERIFY(m_model->workspaces().at(0)->tabs().at(0)->active()); QCOMPARE(m_model->focusedPaneId(), pane0); QCOMPARE(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)), QStringList{pane0}); auditIndex();}
void TestAppModel::moveTabReordersAndKeepsActiveTab(){ // Three tabs; tab 1 (middle) is active. Record the tab object pointers. m_model->newTab(0); m_model->newTab(0); QCOMPARE(m_model->workspaces().at(0)->tabs().size(), 3); m_model->selectTab(0, 1); const auto& tabs = m_model->workspaces().at(0)->tabs(); XenoTab* first = tabs.at(0); XenoTab* second = tabs.at(1); XenoTab* third = tabs.at(2); QVERIFY(second->active()); const QString activeIdBefore = m_model->activeTabId(); const QString pane1 = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).constFirst(); m_model->focusPane(pane1);
// Move the active tab from index 1 to index 2. m_model->moveTab(0, 1, 2); QCOMPARE((QList<XenoTab*>{tabs.at(0), tabs.at(1), tabs.at(2)}), (QList<XenoTab*>{first, third, second})); QCOMPARE(m_model->activeTabId(), activeIdBefore); QVERIFY(second->active()); QCOMPARE(m_model->workspaces().at(0)->activeTabIndex(), 2); QCOMPARE(m_model->activePath().tabId, activeIdBefore); QCOMPARE(m_model->focusedPaneId(), pane1); auditIndex();
// Move it back left; the previously-second tab follows its object. m_model->moveTab(0, 2, 0); QCOMPARE((QList<XenoTab*>{tabs.at(0), tabs.at(1), tabs.at(2)}), (QList<XenoTab*>{second, first, third})); QCOMPARE(m_model->activeTabId(), activeIdBefore); QCOMPARE(m_model->workspaces().at(0)->activeTabIndex(), 0); auditIndex();
// Non-active move: the active tab stays put, only the order changes. m_model->moveTab(0, 1, 2); QCOMPARE((QList<XenoTab*>{tabs.at(0), tabs.at(1), tabs.at(2)}), (QList<XenoTab*>{second, third, first})); QCOMPARE(m_model->activeTabId(), activeIdBefore); QCOMPARE(m_model->workspaces().at(0)->activeTabIndex(), 0); auditIndex();
// End moves are no-ops (left at 0, right at count-1). m_model->moveTab(0, 0, -5); m_model->moveTab(0, 2, 99); QCOMPARE((QList<XenoTab*>{tabs.at(0), tabs.at(1), tabs.at(2)}), (QList<XenoTab*>{second, third, first})); QCOMPARE(m_model->activeTabId(), activeIdBefore);
// Out-of-range source index: no-op. Single tab: no-op. m_model->moveTab(0, -1, 0); m_model->moveTab(0, 3, 0); QCOMPARE((QList<XenoTab*>{tabs.at(0), tabs.at(1), tabs.at(2)}), (QList<XenoTab*>{second, third, first})); QCOMPARE(m_model->activeTabId(), activeIdBefore); auditIndex();}
void TestAppModel::paneKindRoundTrip(){ bootModelFromFixture(kindFixture()); SplitContainerNode* tree = m_model->tabTree(0, 0); QVERIFY(tree != nullptr); const QStringList ids = paneIdsInLayoutOrder(*tree); QCOMPARE(static_cast<int>(ids.size()), 2); const QString& editorId = ids.at(0); const QString& pickerId = ids.at(1); const PaneNode* editorLeaf = paneNodeIn(*tree, editorId); const PaneNode* pickerLeaf = paneNodeIn(*tree, pickerId); QVERIFY(editorLeaf != nullptr); QVERIFY(pickerLeaf != nullptr); QCOMPARE(editorLeaf->paneKind(), PaneKind::Editor); QCOMPARE(pickerLeaf->paneKind(), PaneKind::Picker); QCOMPARE(m_model->paneKindOf(editorId), QStringLiteral("editor")); QCOMPARE(m_model->paneKindOf(pickerId), QStringLiteral("picker"));
// A chooser is unstarted work: it saves as a terminal. The editor keeps // its file. const QVariantMap treeJson = tabTreeInSession(m_model->collectSession(false), 0); const QVariantMap pickerJson = sessionLeafIn(treeJson, pickerId); QCOMPARE(pickerJson.value(QLatin1String("paneKind")).toString(), QStringLiteral("terminal")); const QVariantMap editorJson = sessionLeafIn(treeJson, editorId); QCOMPARE(editorJson.value(QLatin1String("paneKind")).toString(), QStringLiteral("editor")); QCOMPARE(editorJson.value(QLatin1String("editorPath")).toString(), QStringLiteral("/tmp/kind.txt"));}
void TestAppModel::editorLeafWithoutPathRestoresAsPicker(){ // An editor pane always has a file, so a leaf that names no file is a // chooser. bootModelFromFixture(editorLeafWithoutPathFixture()); SplitContainerNode* tree = m_model->tabTree(0, 0); QVERIFY(tree != nullptr); const QStringList ids = paneIdsInLayoutOrder(*tree); QCOMPARE(static_cast<int>(ids.size()), 1); const PaneNode* leaf = paneNodeIn(*tree, ids.first()); QVERIFY(leaf != nullptr); QCOMPARE(leaf->paneKind(), PaneKind::Picker); QCOMPARE(m_model->paneKindOf(ids.first()), QStringLiteral("picker"));}
void TestAppModel::keepOnlyPaneKeepsKinds(){ const QString start = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(start, SplitDirection::Right); m_model->splitPane(start, SplitDirection::Right); const QStringList ids = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QCOMPARE(static_cast<int>(ids.size()), 3); const QString& editorId = ids.at(1); m_model->setPaneKindFromString(editorId, QStringLiteral("editor"), QStringLiteral("/tmp/keep.txt")); QCOMPARE(m_model->paneKindOf(editorId), QStringLiteral("editor"));
m_model->keepOnlyPane(editorId); const QStringList after = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)); QCOMPARE(after, QStringList{editorId}); const PaneNode* survivor = paneNodeIn(*m_model->tabTree(0, 0), editorId); QVERIFY(survivor != nullptr); QCOMPARE(survivor->paneKind(), PaneKind::Editor); QCOMPARE(m_model->paneKindOf(editorId), QStringLiteral("editor")); auditIndex();}
void TestAppModel::splitPaneInheritsTheSourceKind(){ // A terminal source gives a terminal. const QString start = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); m_model->splitPane(start, SplitDirection::Right); const QString termId = m_model->focusedPaneId(); QVERIFY(termId != start); QCOMPARE(m_model->paneKindOf(termId), QStringLiteral("terminal"));
// An editor source gives an editor request with no file, which becomes a // file-only chooser. m_model->setPaneKindFromString(start, QStringLiteral("editor"), QStringLiteral("/tmp/inherit.txt")); QCOMPARE(m_model->paneKindOf(start), QStringLiteral("editor")); m_model->splitPane(start, SplitDirection::Right); const QString pickerId = m_model->focusedPaneId(); QVERIFY(pickerId != start); QVERIFY(pickerId != termId); QCOMPARE(m_model->paneKindOf(pickerId), QStringLiteral("picker")); QVERIFY(m_model->panePickerFilesOnly(pickerId)); auditIndex();}
void TestAppModel::splitPaneWithKindReportsThePaneId(){ const QString anchor = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); const QString editorId = m_model->splitPaneWithKind( anchor, QStringLiteral("right"), QStringLiteral("editor"), QStringLiteral("/tmp/kind.txt"), 42, 3); QVERIFY(!editorId.isEmpty()); QVERIFY(paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).contains(editorId)); QCOMPARE(m_model->paneKindOf(editorId), QStringLiteral("editor")); QCOMPARE(m_model->paneRecord(editorId).editorPath, QStringLiteral("/tmp/kind.txt")); QCOMPARE(m_model->paneRecord(editorId).editorLine, 42); QCOMPARE(m_model->paneRecord(editorId).editorColumn, 3); QVERIFY(!m_model->panePickerFilesOnly(editorId));
// An editor request with an empty path is a file-only chooser. const QString pickerId = m_model->splitPaneWithKind( anchor, QStringLiteral("right"), QStringLiteral("editor"), QString(), 0, 0); QVERIFY(!pickerId.isEmpty()); QCOMPARE(m_model->paneKindOf(pickerId), QStringLiteral("picker")); QVERIFY(m_model->panePickerFilesOnly(pickerId)); QVERIFY(m_model->paneRecord(pickerId).editorPath.isEmpty());
// An unknown kind word reads as a terminal. const QString unknownId = m_model->splitPaneWithKind(anchor, QStringLiteral("right"), QStringLiteral("nonsense"), QString(), 0, 0); QCOMPARE(m_model->paneKindOf(unknownId), QStringLiteral("terminal")); auditIndex();}
void TestAppModel::editorPaneShowingPathScopesToOneTab(){ const QString path = QStringLiteral("/tmp/scope.txt"); const QString anchor0 = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); const QString editor0 = m_model->splitPaneWithKind( anchor0, QStringLiteral("right"), QStringLiteral("editor"), path, 0, 0); m_model->newTab(0); auditIndex(); const QString anchor1 = paneIdsInLayoutOrder(*m_model->tabTree(0, 1)).first(); const QString editor1 = m_model->splitPaneWithKind( anchor1, QStringLiteral("right"), QStringLiteral("editor"), path, 0, 0); QVERIFY(!editor0.isEmpty()); QVERIFY(!editor1.isEmpty()); QVERIFY(editor0 != editor1);
// Each tab finds its own editor, never the other tab's. QCOMPARE(m_model->editorPaneShowingPath(editor0, path), editor0); QCOMPARE(m_model->editorPaneShowingPath(editor1, path), editor1); QCOMPARE(m_model->editorPaneShowingPath(anchor0, path), editor0); QCOMPARE(m_model->editorPaneShowingPath(anchor1, path), editor1);}
void TestAppModel::dirtyEditorPanesFollowsTheNote(){ const QString anchor = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); const QString editorId = m_model->splitPaneWithKind( anchor, QStringLiteral("right"), QStringLiteral("editor"), QStringLiteral("/tmp/dirty.txt"), 0, 0); QVERIFY(!m_model->paneDirty(editorId)); QVERIFY(m_model->dirtyEditorPanesInTab(0, 0).isEmpty()); QVERIFY(m_model->dirtyEditorPanes().isEmpty());
m_model->notePaneDirty(editorId, true); QVERIFY(m_model->paneDirty(editorId)); QVERIFY(m_model->dirtyEditorPanesInTab(0, 0).contains(editorId)); QVERIFY(m_model->dirtyEditorPanes().contains(editorId));
m_model->notePaneDirty(editorId, false); QVERIFY(!m_model->paneDirty(editorId)); QVERIFY(!m_model->dirtyEditorPanesInTab(0, 0).contains(editorId)); QVERIFY(!m_model->dirtyEditorPanes().contains(editorId));}
void TestAppModel::setPaneKindClearsTheOldPayload(){ const QString anchor = paneIdsInLayoutOrder(*m_model->tabTree(0, 0)).first(); const QString editorId = m_model->splitPaneWithKind( anchor, QStringLiteral("right"), QStringLiteral("editor"), QStringLiteral("/tmp/payload.txt"), 0, 0); QCOMPARE(m_model->paneKindOf(editorId), QStringLiteral("editor")); QCOMPARE(m_model->editorPaneShowingPath(anchor, QStringLiteral("/tmp/payload.txt")), editorId);
m_model->setPaneKindFromString(editorId, QStringLiteral("terminal"), QString()); QCOMPARE(m_model->paneKindOf(editorId), QStringLiteral("terminal")); QVERIFY(m_model->editorPaneShowingPath(anchor, QStringLiteral("/tmp/payload.txt")) .isEmpty()); QVERIFY(m_model->paneRecord(editorId).editorPath.isEmpty()); auditIndex();}
QTEST_MAIN(TestAppModel)#include "tst_appmodel.moc"