Modules/Taskboard/tests/TaskBoardTests.cpp
claude-opus-5-5 ba1f72844f Taskboard: контекстное меню задачи в дереве и канбане
Правый клик по карточке открывает общее меню быстрых действий: открыть,
новая дочерняя задача, статус, управление сессией, копировать/вставить,
удалить. На пустом месте — новая задача и вставка (в канбане — в статус
колонки). TreeView и KanbanBoard получили хук setOnContextMenu; меню
строится одним TaskContextMenu для обоих видов. Ctrl+C/Ctrl+V копируют
задачи вместе с поддеревом.
2026-10-28 14:22:35 +03:00

2978 lines
108 KiB
C++

#include "BoxLayout.hpp"
#include "DemoBoard.hpp"
#include "TaskBoard.hpp"
#include "TaskTreeView.hpp"
#include "ApplicationFrame.hpp"
#include "Menus.hpp"
#include "NodeEditor.hpp"
#include "MemoryFileSystem.hpp"
#include "SessionPanel.hpp"
#include "TaskCard.hpp"
#include "TaskStore.hpp"
#include "UiDriver.hpp"
#include <algorithm>
#include "UnitTest++/UnitTest++.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
using namespace tp;
using namespace tp::taskboard;
using namespace tp::ui;
using namespace tp::ui::testing;
SUITE(TaskBoardModel) {
struct OrderedBoard {
TaskBoard board;
std::vector<NodeId> ids;
explicit OrderedBoard(std::vector<int> orders) {
for (int order : orders)
ids.push_back(board.addTask({ .order = order }, { 0, 0 }));
}
std::vector<NodeId> inOrder(std::initializer_list<size_t> indices) const {
std::vector<NodeId> result;
for (size_t index : indices)
result.push_back(ids[index]);
return result;
}
};
TEST(PlacingATaskMovesItAndOnlyRenumbersTasksInBetween) {
OrderedBoard scene({ 10, 20, 30, 40, 50 });
scene.board.placeBefore(scene.ids[3], scene.ids[1]);
CHECK((scene.board.tasksInOrder() == scene.inOrder({ 0, 3, 1, 2, 4 })));
CHECK_EQUAL(10, scene.board.task(scene.ids[0])->order);
CHECK_EQUAL(50, scene.board.task(scene.ids[4])->order);
scene.board.placeAfter(scene.ids[0], scene.ids[2]);
CHECK((scene.board.tasksInOrder() == scene.inOrder({ 3, 1, 2, 0, 4 })));
CHECK_EQUAL(50, scene.board.task(scene.ids[4])->order);
}
TEST(PlacingRenumbersEverythingWhenRanksCollide) {
OrderedBoard scene({ 0, 0, 0 });
scene.board.placeBefore(scene.ids[2], scene.ids[0]);
CHECK((scene.board.tasksInOrder() == scene.inOrder({ 2, 0, 1 })));
CHECK_EQUAL(0, scene.board.task(scene.ids[2])->order);
CHECK_EQUAL(2, scene.board.task(scene.ids[1])->order);
}
TEST(ReparentedTaskGoesLastAmongItsNewSiblings) {
OrderedBoard scene({ 0, 1, 2, 3 });
scene.board.setParent(scene.ids[1], scene.ids[0]);
scene.board.setParent(scene.ids[3], scene.ids[0]);
scene.board.setParent(scene.ids[2], scene.ids[0]);
scene.board.placeLastAmongSiblings(scene.ids[2]);
CHECK((scene.board.childrenOf(scene.ids[0]) == scene.inOrder({ 1, 3, 2 })));
CHECK_EQUAL(scene.board.orderAfterLast() - 1, scene.board.task(scene.ids[2])->order);
}
TEST(DependenciesGateRunnableTasks) {
TaskBoard board;
const NodeId first = board.addTask({ .status = TaskStatus::Done }, { 0, 0 });
const NodeId second = board.addTask({ .status = TaskStatus::Wip }, { 300, 0 });
const NodeId last = board.addTask({ .status = TaskStatus::Queued }, { 600, 0 });
CHECK(board.addDependency(last, first));
CHECK(board.addDependency(last, second));
CHECK(!board.isRunnable(last));
board.task(second)->status = TaskStatus::Done;
CHECK(board.isRunnable(last));
}
TEST(DependencyCyclesAreRejected) {
TaskBoard board;
const NodeId first = board.addTask({}, { 0, 0 });
const NodeId second = board.addTask({}, { 300, 0 });
const NodeId third = board.addTask({}, { 600, 0 });
CHECK(board.addDependency(second, first));
CHECK(board.addDependency(third, second));
CHECK(!board.addDependency(first, third));
}
TEST(TaskHasSingleParentAndHierarchyCyclesAreRejected) {
TaskBoard board;
const NodeId root = board.addTask({}, { 0, 0 });
const NodeId other = board.addTask({}, { 0, 300 });
const NodeId child = board.addTask({}, { 300, 0 });
CHECK(board.setParent(child, root));
CHECK(board.setParent(child, other));
CHECK_EQUAL(other, *board.parentOf(child));
CHECK(!board.setParent(other, child));
}
TEST(RelationQueriesFollowLinks) {
TaskBoard board;
const NodeId root = board.addTask({}, { 0, 0 });
const NodeId child = board.addTask({}, { 0, 0 });
const NodeId grandchild = board.addTask({}, { 0, 0 });
const NodeId other = board.addTask({}, { 0, 0 });
board.setParent(child, root);
board.setParent(grandchild, child);
board.addDependency(other, child);
CHECK((board.rootTasks() == std::vector<NodeId>{ root, other }));
CHECK(board.childrenOf(root) == std::vector<NodeId>{ child });
CHECK((board.ancestorsOf(grandchild) == (std::vector<NodeId>{ root, child })));
CHECK(board.dependentsOf(child) == std::vector<NodeId>{ other });
CHECK(board.dependenciesOf(other) == std::vector<NodeId>{ child });
board.removeDependency(other, child);
CHECK(board.dependentsOf(child).empty());
board.clearParent(child);
CHECK((board.rootTasks() == (std::vector<NodeId>{ root, child, other })));
}
TEST(LinkChecksRejectSelfMissingAndCycles) {
TaskBoard board;
const NodeId first = board.addTask({}, { 0, 0 });
const NodeId second = board.addTask({}, { 0, 0 });
board.addDependency(second, first);
board.setParent(second, first);
CHECK(!board.canAddDependency(first, first));
CHECK(!board.canAddDependency(first, 999));
CHECK(!board.canAddDependency(first, second));
CHECK(board.canAddDependency(second, first));
CHECK(!board.canSetParent(first, second));
CHECK(board.canSetParent(second, first));
}
TEST(HierarchyAndDependencyPortsDoNotMix) {
TaskBoard board;
const NodeId first = board.addTask({}, { 0, 0 });
const NodeId second = board.addTask({}, { 300, 0 });
CHECK(!board.graph().connect(
TaskBoard::output(first, TaskOutput::Children), TaskBoard::input(second, TaskPort::DependsOn)
));
}
}
namespace {
bool shownOnScreen(const Widget& widget) {
for (const Widget* current = &widget; current; current = current->parent())
if (!current->isVisible()) return false;
return widget.ui() != nullptr;
}
Button* findButton(Widget& root, const std::string& text) {
if (auto* button = dynamic_cast<Button*>(&root); button && button->text() == text) return button;
for (Widget* child : root.children())
if (Button* found = findButton(*child, text)) return found;
return nullptr;
}
struct RecordingStore : TaskStore {
void startSession(NodeId id) override { started.push_back(id); }
void restartSession(NodeId id, bool fresh) override { restarts.emplace_back(id, fresh); }
void stopSession(NodeId id) override { stopped.push_back(id); }
void beginEditing(NodeId id, TaskField field) override { editing = std::make_pair(id, field); }
void endEditing() override { editing.reset(); }
std::optional<std::pair<NodeId, TaskField>> editing;
std::vector<NodeId> started;
std::vector<std::pair<NodeId, bool>> restarts;
std::vector<NodeId> stopped;
};
struct TreeScene {
TaskBoard board;
NodeId widgets = board.addTask({ .title = "Widgets" }, { 0, 0 });
NodeId textEdit = board.addTask({ .title = "Text edit glitches", .description = "space and emoji" }, { 0, 0 });
NodeId emoji =
board.addTask({ .title = "Emoji fallback", .description = "copy NotoEmoji into the assets" }, { 0, 0 });
NodeId release = board.addTask({ .title = "Release notes", .workspace = "/agents/task-4" }, { 0, 0 });
MemoryFileSystem files;
ApplicationFrame window;
AppStorage storage{ "/home/user/taskboard" };
TaskTreeView view{ board, files, storage };
RecordingStore store;
int edits = 0;
explicit TreeScene(bool panelsOpen = true) {
files.addTextFile("/agents/task-4/reports/summary.md", "# Summary\n\nAll done.");
files.addTextFile("/agents/task-4/reports/data.csv", "a,b");
files.addDirectory("/agents/task-4/notes");
window.setContent(&view);
view.attachMenus(window.menuBar, [] {});
view.dock().setViewMode(view.taskPanel(), DockPanel::ViewMode::Docked);
view.dock().hidePanel(view.taskPanel());
if (panelsOpen) view.openPanels();
view.taskWidget().showPage(TaskWidgetPage::Links);
board.setParent(textEdit, widgets);
board.setParent(emoji, textEdit);
view.boardReloaded();
view.setStore(&store);
view.setOnBoardEdited([this] { edits++; });
}
TaskDetails& details() { return view.details(); }
void showHeader(UiDriver& driver) {
view.taskWidget().showPage(TaskWidgetPage::Task);
driver.settle();
}
void chooseAction(UiDriver& driver, const std::string& text) {
showHeader(driver);
driver.click(view.header().actionsButton);
driver.settle();
for (const auto& item : view.header().actionsMenu.items()) {
if (item->text() != text) continue;
driver.click(*item);
driver.settle();
return;
}
CHECK(false);
}
};
}
SUITE(TaskTreeNavigation) {
TEST(StartsAtBoardListingTopLevelTasks) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
CHECK(!scene.view.currentTask());
CHECK_EQUAL(1u, scene.view.pages().depth());
CHECK_EQUAL(1u, scene.view.breadcrumb().partCount());
CHECK(scene.details().row(scene.details().children, scene.widgets) != nullptr);
CHECK(scene.details().row(scene.details().children, scene.release) != nullptr);
CHECK(scene.details().row(scene.details().children, scene.textEdit) == nullptr);
CHECK(scene.view.page().rootHint.isVisible());
CHECK(!shownOnScreen(scene.view.header().title));
}
TEST(SingleClickOnChildRowCentresTheGraphOnIt) {
TreeScene scene;
scene.board.task(scene.release)->order = 9;
UiDriver driver(scene.window, { 1200, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
driver.click(scene.details().row(scene.details().children, scene.release)->openButton());
driver.settle();
CHECK(!scene.view.currentTask());
const RectF card = *graph.cardRect(scene.release);
const Vec2F centre = card.pos + card.size / 2.0f;
CHECK_CLOSE(graph.size().x / 2, centre.x, 1.0f);
CHECK_CLOSE(graph.size().y / 2, centre.y, 1.0f);
}
TEST(OpeningChildRowPushesPageAndBackReturns) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
driver.settle();
Button& row = scene.details().row(scene.details().children, scene.widgets)->openButton();
const Vec2F at = row.windowRect().center();
driver.click(at);
driver.click(at);
driver.settle();
CHECK(scene.view.currentTask() == scene.widgets);
CHECK_EQUAL(2u, scene.view.pages().depth());
CHECK_EQUAL("Widgets", scene.view.header().title.text());
CHECK(scene.details().row(scene.details().children, scene.textEdit) != nullptr);
CHECK(!shownOnScreen(scene.view.pages().currentPage().backButton()));
driver.click(scene.view.breadcrumb().part(0));
driver.settle();
CHECK(!scene.view.currentTask());
CHECK_EQUAL(1u, scene.view.pages().depth());
}
TEST(OpeningDeepTaskBuildsItsParentPath) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
CHECK_EQUAL(4u, scene.view.pages().depth());
REQUIRE CHECK_EQUAL(4u, scene.view.breadcrumb().partCount());
CHECK_EQUAL("Board", scene.view.breadcrumb().part(0).text());
CHECK_EQUAL("Text edit glitches", scene.view.breadcrumb().part(2).text());
driver.click(scene.view.breadcrumb().part(1));
driver.settle();
CHECK(scene.view.currentTask() == scene.widgets);
CHECK_EQUAL(2u, scene.view.pages().depth());
}
TEST(SwitchingToUnrelatedTaskReplacesPath) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
scene.view.openTask(scene.release);
driver.settle();
CHECK(scene.view.currentTask() == scene.release);
CHECK_EQUAL(2u, scene.view.pages().depth());
}
TEST(SectionsStartExpandedAndKeepUserChoice) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.widgets);
driver.settle();
for (RelationSection* section :
{ &scene.details().children, &scene.details().dependsOn, &scene.details().unblocks })
CHECK(section->isExpanded());
CHECK_EQUAL("1", scene.details().children.header().summary());
CHECK(scene.details().row(scene.details().children, scene.textEdit) != nullptr);
driver.click(scene.details().dependsOn.header());
driver.settle();
CHECK(!scene.details().dependsOn.isExpanded());
scene.view.openTask(scene.release);
CHECK(!scene.details().dependsOn.isExpanded());
}
TEST(DraggingChildrenReordersThemAndRecordsOrder) {
TreeScene scene;
const NodeId second = scene.board.addTask({ .title = "Second", .order = 1 }, { 0, 0 });
const NodeId third = scene.board.addTask({ .title = "Third", .order = 2 }, { 0, 0 });
scene.board.setParent(second, scene.widgets);
scene.board.setParent(third, scene.widgets);
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.widgets);
driver.settle();
ReorderableList& list = scene.details().children.list();
REQUIRE CHECK_EQUAL(3u, list.itemCount());
const Vec2F grip = list.windowPosition() + list.gripRect(2).center();
const Vec2F top = list.windowPosition() + list.gripRect(0).center() - Vec2F(0, 8);
scene.edits = 0;
driver.drag(grip, top, 6);
driver.settle();
CHECK(scene.board.childrenOf(scene.widgets) == (std::vector<NodeId>{ third, scene.textEdit, second }));
CHECK(scene.board.task(third)->order < scene.board.task(scene.textEdit)->order);
CHECK(scene.board.task(scene.textEdit)->order < scene.board.task(second)->order);
CHECK(scene.edits > 0);
}
TEST(ChildRowShowsColorAsSwatchNotFill) {
TreeScene scene;
scene.board.task(scene.widgets)->color = RGBA(1, 0, 0, 1);
scene.view.boardReloaded();
UiDriver driver(scene.window, { 1200, 900 });
driver.settle();
TaskRowButton& button = scene.details().row(scene.details().children, scene.widgets)->openButton();
CHECK(button.swatch() == RGBA(1, 0, 0, 1));
const RectF row = button.windowRect();
const RectF panel = scene.view.pages().windowRect();
size_t redFills = 0;
bool rowFilledRed = false;
for (const PaintCommand& command : driver.paint().commands()) {
if (command.kind != PaintCommand::Kind::FillRect || !(command.color == RGBA(1, 0, 0, 1))) continue;
const bool inPanel = command.rect.pos.x >= panel.pos.x - 1 &&
command.rect.pos.x + command.rect.size.x <= panel.pos.x + panel.size.x + 1;
if (!inPanel) continue;
redFills++;
rowFilledRed |= command.rect.size.x >= row.size.x - 1;
}
CHECK_EQUAL(1u, redFills);
CHECK(!rowFilledRed);
}
TEST(NewChildGoesLastInTheGlobalOrder) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.board.task(scene.textEdit)->order = 4;
scene.board.task(scene.release)->order = 7;
scene.view.openTask(scene.widgets);
scene.view.createTask();
REQUIRE CHECK(scene.view.currentTask());
CHECK_EQUAL(8, scene.board.task(*scene.view.currentTask())->order);
CHECK(scene.board.childrenOf(scene.widgets).back() == *scene.view.currentTask());
}
TEST(DeleteAsksThenMovesChildrenUpKeepingTheirPlaceAndReturnsToParent) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.board.task(scene.release)->order = 9;
scene.view.openTask(scene.widgets);
driver.settle();
scene.chooseAction(driver, "Delete task...");
driver.settle();
REQUIRE CHECK(scene.view.header().deleteConfirm.isOpen());
CHECK(scene.board.task(scene.widgets) != nullptr);
scene.view.header().deleteConfirm.accept();
driver.settle();
CHECK(scene.board.task(scene.widgets) == nullptr);
CHECK(!scene.view.currentTask());
CHECK(!scene.board.parentOf(scene.textEdit));
CHECK(scene.board.parentOf(scene.emoji) == scene.textEdit);
CHECK(scene.board.rootTasks() == (std::vector<NodeId>{ scene.textEdit, scene.release }));
CHECK(scene.edits > 0);
}
TEST(DeleteFromMenuCanBeCancelled) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
for (const auto& item : scene.view.editMenu.items())
if (item->text() == "Delete Task") item->activate();
driver.settle();
REQUIRE CHECK(scene.view.header().deleteConfirm.isOpen());
scene.view.header().deleteConfirm.cancel();
driver.settle();
CHECK(scene.board.task(scene.emoji) != nullptr);
CHECK(scene.view.currentTask() == scene.emoji);
}
TEST(TaskRemovedByReloadFallsBackToBoard) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.release);
scene.board.graph().removeNodes({ scene.release });
scene.view.boardReloaded();
driver.settle();
CHECK(!scene.view.currentTask());
CHECK_EQUAL(1u, scene.view.pages().depth());
}
}
SUITE(TaskTreeSearch) {
TEST(SearchMatchesIdTitleAndDescriptionInThatOrder) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
const std::vector<SearchResult> byId = scene.view.searchResults("#3");
REQUIRE CHECK_EQUAL(1u, byId.size());
CHECK_EQUAL("#3 Emoji fallback", byId[0].title);
CHECK(byId[0].detail.find("Widgets \u203A Text edit glitches") != std::string::npos);
const std::vector<SearchResult> byText = scene.view.searchResults("emoji");
REQUIRE CHECK_EQUAL(2u, byText.size());
CHECK_EQUAL("#3 Emoji fallback", byText[0].title);
CHECK_EQUAL("#2 Text edit glitches", byText[1].title);
CHECK(byText[1].detail.find("\"space and emoji\"") != std::string::npos);
CHECK(scene.view.searchResults("NOTOEMOJI").size() == 1);
CHECK(scene.view.searchResults("").empty());
CHECK(scene.view.searchResults("nothing like this").empty());
}
TEST(ChoosingSearchResultOpensTask) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
driver.key(InputID::F, { .control = true });
CHECK(scene.view.search().input().isEditing());
driver.type("emoji");
driver.settle();
REQUIRE CHECK(scene.view.search().isShowingResults());
driver.key(InputID::ENTER);
driver.settle();
CHECK(scene.view.currentTask() == scene.emoji);
CHECK_EQUAL(4u, scene.view.pages().depth());
CHECK(scene.view.search().input().text().empty());
}
}
SUITE(TaskTreeEditing) {
TEST(TitleRowShowsIdAndHidesBadgeWithoutSession) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
scene.showHeader(driver);
TaskHeader& page = scene.view.header();
CHECK_EQUAL("#3", page.idLabel.text());
CHECK(page.idLabel.windowRect().pos.y + page.idLabel.windowRect().size.y <= page.title.windowRect().pos.y + 1);
CHECK(page.idLabel.parent() == &page.badgeRow());
CHECK(!page.badge.isVisible());
CHECK(!driver.paint().hasText("\u25CF no session"));
scene.board.task(scene.emoji)->session.state = SessionState::Working;
scene.view.boardReloaded();
driver.settle();
CHECK(page.badge.isVisible());
CHECK(page.badge.isPulsing());
CHECK(page.badge.isDotShown());
CHECK(page.actionsButton.windowRect().pos.x > page.badge.windowRect().pos.x);
const RectF id = page.idLabel.windowRect();
CHECK(page.badge.windowRect().pos.x > id.pos.x + id.size.x + 40);
CHECK(page.badge.windowRect().pos.x > page.status.windowRect().pos.x);
}
TEST(DockedPanelShowsTheBadgeRowAboveTheTitleAndLinksHaveNoPageHeader) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.textEdit);
driver.settle();
CHECK(!shownOnScreen(scene.view.pages().currentPage().header()));
CHECK(!driver.paint().hasText("#2 Text edit glitches"));
scene.showHeader(driver);
TaskHeader& header = scene.view.header();
CHECK(header.badgeRow().parent() != &header);
CHECK(shownOnScreen(header.idLabel));
CHECK(
header.badgeRow().windowRect().pos.y + header.badgeRow().windowRect().size.y <=
header.title.windowRect().pos.y + 1
);
}
TEST(TitleRowHoldsStatusAndColorAndSectionHeadersHoldAdd) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.release);
driver.settle();
scene.showHeader(driver);
TaskHeader& header = scene.view.header();
const halnf badgeY = header.idLabel.windowRect().center().y;
CHECK_CLOSE(badgeY, header.status.windowRect().center().y, 2.0f);
CHECK_CLOSE(badgeY, header.color.windowRect().center().y, 2.0f);
CHECK(header.title.windowRect().pos.y > header.status.windowRect().pos.y);
CHECK(header.title.windowRect().size.y > header.status.windowRect().size.y);
scene.view.taskWidget().showPage(TaskWidgetPage::Links);
driver.settle();
CHECK(shownOnScreen(header.status));
CHECK(!shownOnScreen(header.title));
for (RelationSection* section :
{ &scene.details().children, &scene.details().dependsOn, &scene.details().unblocks }) {
CHECK(section->header().action() == &section->addButton());
CHECK(!section->isPickerShown());
CHECK_CLOSE(section->header().windowRect().center().y, section->addButton().windowRect().center().y, 2.0f);
}
}
TEST(EditingFieldsChangesTaskAndReportsEdits) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.release);
driver.settle();
scene.showHeader(driver);
driver.click(scene.view.header().title);
driver.key(InputID::END);
driver.type("!");
scene.view.header().status.setSelectedIndex(
long(std::find(kTaskStatuses.begin(), kTaskStatuses.end(), TaskStatus::Queued) - kTaskStatuses.begin())
);
const Task& task = *scene.board.task(scene.release);
CHECK_EQUAL("Release notes!", task.title);
CHECK(task.status == TaskStatus::Queued);
CHECK(scene.edits >= 2);
CHECK_EQUAL("Release notes!", scene.view.pages().currentPage().title());
}
TEST(AddChildOpensSearchWithCreateNewFirst) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.board.task(scene.widgets)->agentModel = "claude-opus-4-8";
scene.view.openTask(scene.widgets);
driver.settle();
driver.click(scene.details().children.addButton());
driver.settle();
SearchField& picker = scene.details().children.picker();
REQUIRE CHECK(picker.isShowingResults());
CHECK(picker.input().isEditing());
REQUIRE CHECK(picker.rows().size() >= 2);
CHECK_EQUAL("+ Create new: New task", picker.rows()[0]->result().title);
CHECK_EQUAL(0, picker.highlighted());
driver.type("Docs");
driver.settle();
CHECK_EQUAL("+ Create new: Docs", picker.rows()[0]->result().title);
driver.key(InputID::ENTER);
driver.settle();
const std::optional<NodeId> created = scene.view.currentTask();
REQUIRE CHECK(created && *created != scene.widgets);
CHECK_EQUAL("Docs", scene.board.task(*created)->title);
CHECK(scene.board.parentOf(*created) == scene.widgets);
CHECK_EQUAL("claude-opus-4-8", scene.board.task(*created)->agentModel);
CHECK(scene.view.header().title.isEditing());
}
TEST(AddChildCanAdoptExistingTask) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.widgets);
driver.settle();
driver.click(scene.details().children.addButton());
driver.type("Release");
driver.settle();
SearchField& picker = scene.details().children.picker();
REQUIRE CHECK_EQUAL(2u, picker.rows().size());
driver.key(InputID::DOWN);
driver.key(InputID::ENTER);
driver.settle();
CHECK(scene.view.currentTask() == scene.widgets);
CHECK(scene.board.parentOf(scene.release) == scene.widgets);
CHECK(scene.board.childrenOf(scene.widgets).back() == scene.release);
driver.click(scene.details().children.addButton());
driver.type("Widgets");
driver.settle();
CHECK_EQUAL(1u, picker.rows().size());
}
TEST(CtrlNOnBoardCreatesTopLevelTask) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
driver.key(InputID::N, { .control = true });
driver.settle();
REQUIRE CHECK(scene.view.currentTask());
CHECK(!scene.board.parentOf(*scene.view.currentTask()));
CHECK_EQUAL(5u, scene.board.graph().nodes().size());
}
TEST(DependencyPickerLinksAndBlocksCycles) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.board.addDependency(scene.widgets, scene.release);
scene.view.openTask(scene.release);
driver.settle();
driver.click(scene.details().dependsOn.addButton());
driver.settle();
SearchField& picker = scene.details().dependsOn.picker();
REQUIRE CHECK(scene.details().dependsOn.isPickerShown());
CHECK(picker.input().isEditing());
driver.type("Widgets");
driver.settle();
REQUIRE CHECK_EQUAL(2u, picker.rows().size());
CHECK(!picker.rows()[1]->result().enabled);
CHECK_EQUAL("would create a cycle", picker.rows()[1]->result().detail);
picker.setText("Emoji");
driver.settle();
driver.key(InputID::DOWN);
driver.key(InputID::ENTER);
driver.settle();
CHECK(scene.board.dependenciesOf(scene.release) == std::vector<NodeId>{ scene.emoji });
CHECK(!scene.details().dependsOn.isPickerShown());
CHECK(scene.details().row(scene.details().dependsOn, scene.emoji) != nullptr);
CHECK(scene.details().row(scene.details().unblocks, scene.widgets) != nullptr);
}
TEST(DependencyCreateNewMakesSiblingAndLinksIt) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
driver.click(scene.details().dependsOn.addButton());
driver.type("Fonts");
driver.settle();
driver.key(InputID::ENTER);
driver.settle();
const std::optional<NodeId> created = scene.view.currentTask();
REQUIRE CHECK(created && *created != scene.emoji);
CHECK_EQUAL("Fonts", scene.board.task(*created)->title);
CHECK(scene.board.parentOf(*created) == scene.textEdit);
CHECK(scene.board.dependenciesOf(scene.emoji) == std::vector<NodeId>{ *created });
}
TEST(UnblocksPickerAddsDependentAndRemoveUnlinks) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
driver.click(scene.details().unblocks.addButton());
driver.type("#4");
driver.settle();
driver.key(InputID::DOWN);
driver.key(InputID::ENTER);
driver.settle();
CHECK(scene.board.dependenciesOf(scene.release) == std::vector<NodeId>{ scene.emoji });
driver.click(scene.details().row(scene.details().unblocks, scene.release)->removeButton());
driver.settle();
CHECK(scene.board.dependenciesOf(scene.release).empty());
CHECK(scene.view.currentTask() == scene.emoji);
}
TEST(SetParentMovesTaskAndUpdatesPath) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
scene.chooseAction(driver, "Set parent...");
driver.settle();
SearchField& picker = scene.view.header().parentPicker;
REQUIRE CHECK(picker.isShowingResults());
CHECK_EQUAL("Top level", picker.rows()[0]->result().title);
driver.type("Release");
driver.settle();
REQUIRE CHECK_EQUAL(2u, picker.rows().size());
driver.key(InputID::DOWN);
driver.key(InputID::ENTER);
driver.settle();
CHECK(scene.board.parentOf(scene.emoji) == scene.release);
CHECK(scene.view.currentTask() == scene.emoji);
CHECK_EQUAL(3u, scene.view.pages().depth());
scene.chooseAction(driver, "Set parent...");
driver.settle();
REQUIRE CHECK(picker.isShowingResults());
driver.key(InputID::ENTER);
driver.settle();
CHECK(!scene.board.parentOf(scene.emoji));
CHECK_EQUAL(2u, scene.view.pages().depth());
scene.chooseAction(driver, "Set parent...");
driver.type("Text edit");
driver.settle();
scene.view.openTask(scene.widgets);
scene.chooseAction(driver, "Set parent...");
driver.type("Emoji");
driver.settle();
REQUIRE CHECK_EQUAL(2u, picker.rows().size());
CHECK(picker.rows()[1]->result().enabled);
}
TEST(TypingReportsEditingScopeAndReloadKeepsIt) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
scene.view.openTask(scene.release);
driver.settle();
scene.showHeader(driver);
MarkdownEditor& editor = scene.view.description().editor;
editor.setMode(MarkdownEditor::Mode::Editor);
driver.settle();
driver.click(editor.sourceArea());
driver.type("draft");
REQUIRE CHECK(scene.store.editing.has_value());
CHECK(scene.store.editing->second == TaskField::Description);
scene.view.boardReloaded();
CHECK_EQUAL("draft", editor.markdown());
CHECK_EQUAL("draft", scene.board.task(scene.release)->description);
driver.click(scene.view.header().title);
REQUIRE CHECK(scene.store.editing.has_value());
CHECK(scene.store.editing->second == TaskField::Title);
}
TEST(ConflictBannerShowsAndReportsChoice) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
std::vector<bool> choices;
scene.view.header().setOnConflictChoice([&](bool keepMine) { choices.push_back(keepMine); });
scene.view.openTask(scene.release);
scene.view.showConflicts({ "#4 Title: theirs \"A\", yours \"B\"" });
driver.settle();
scene.showHeader(driver);
CHECK(shownOnScreen(scene.view.header().keepMine));
driver.click(scene.view.header().keepMine);
driver.click(scene.view.header().useTheirs);
CHECK((choices == (std::vector<bool>{ true, false })));
scene.view.showConflicts({});
CHECK(!shownOnScreen(scene.view.header().keepMine));
}
TEST(SaveFromCtrlSAndMenuAndStatusShowsUnsavedCount) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
int saves = 0;
scene.view.setOnSave([&] { saves++; });
driver.key(InputID::S, { .control = true });
for (const auto& item : scene.view.fileMenu.items())
if (item->text() == "Save") item->activate();
CHECK_EQUAL(2, saves);
scene.view.showSaveState(3, false);
driver.settle();
CHECK(driver.paint().findText("\u25CF 3 unsaved changes (Ctrl+S)") != nullptr);
scene.view.showSaveState(0, false);
driver.settle();
CHECK(driver.paint().findText("All changes saved") != nullptr);
}
}
SUITE(TaskTreePanels) {
TEST(TaskPanelStartsDockedUnpinnedAndClosed) {
TaskBoard board;
MemoryFileSystem files;
TaskTreeView view{ board, files, AppStorage{ "/home/user/taskboard" } };
CHECK(view.taskPanel().viewMode() == DockPanel::ViewMode::Undocked);
CHECK(view.taskPanel().side() == DockSide::Right);
CHECK(!view.taskPanel().isOpen());
}
TEST(PanelsStartClosedLeavingTheGraphTheWholeWindow) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
CHECK(!scene.view.taskPanel().isOpen());
CHECK(scene.view.graph().size().x > 1300);
scene.view.openPanels();
driver.settle();
CHECK(scene.view.taskPanel().isOpen());
CHECK(scene.view.graph().size().x < 1000);
}
}
SUITE(TaskTreeSession) {
TEST(SessionPanelStaysAtTheBottomAndControlsFollowState) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
scene.view.taskWidget().showPage(TaskWidgetPage::Session);
driver.settle();
CHECK(!scene.view.session().isVisible());
CHECK(driver.paint().hasText("Open a task to start or follow its agent session."));
scene.view.openTask(scene.release);
driver.settle();
CHECK(scene.view.session().isVisible());
SessionTab& session = scene.view.session();
CHECK(session.startSession.isEffectivelyEnabled());
driver.click(session.startSession);
CHECK(scene.store.started == std::vector<NodeId>{ scene.release });
scene.board.task(scene.release)->session = { SessionState::Working, "tool: Edit", std::nullopt };
scene.view.boardReloaded();
driver.settle();
CHECK(!session.startSession.isEffectivelyEnabled());
CHECK(session.stopSession.isEffectivelyEnabled());
CHECK(session.state.text().find("tool: Edit") != std::string::npos);
CHECK(scene.view.header().badge.text().find("running") != std::string::npos);
driver.click(session.stopSession);
CHECK(scene.store.stopped == std::vector<NodeId>{ scene.release });
}
TEST(DshSessionOpensRegardlessOfSessionStateWhileTerminalSessionsNeedAProcess) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Session);
std::vector<NodeId> opened;
scene.view.setOnOpenSession([&opened](NodeId id) { opened.push_back(id); });
scene.view.openTask(scene.release);
driver.settle();
SessionTab& session = scene.view.session();
Task& task = *scene.board.task(scene.release);
task.harness = "claude-code";
task.session = { SessionState::Error, "rate_limit", std::nullopt };
scene.view.boardReloaded();
driver.settle();
CHECK(session.openSession.isEffectivelyEnabled());
task.session = { SessionState::Ended, "stopped from the board", std::nullopt };
scene.view.boardReloaded();
driver.settle();
CHECK(!session.openSession.isEffectivelyEnabled());
task.harness = "dsh";
for (SessionState state : { SessionState::None, SessionState::Ended, SessionState::Error }) {
task.session = { state, "", std::nullopt };
scene.view.boardReloaded();
driver.settle();
CHECK(session.openSession.isEffectivelyEnabled());
}
driver.click(session.openSession);
CHECK(opened == std::vector<NodeId>{ scene.release });
}
TEST(PresetsSitAboveCollapsedParametersAboveTheButtonsAndThereIsNoMessageField) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Session);
scene.view.openTask(scene.release);
driver.settle();
SessionTab& session = scene.view.session();
CHECK(!session.parameters.isExpanded());
CHECK(!session.model.isVisible() || !session.parameters.isBodyShown());
CHECK(session.presets.windowRect().pos.y < session.parameters.windowRect().pos.y);
CHECK(session.parameters.windowRect().pos.y < session.startSession.windowRect().pos.y);
CHECK(!driver.paint().hasText("Message the agent..."));
const Task& task = *scene.board.task(scene.release);
CHECK(driver.paint().hasText(
task.agentModel + " \u00b7 " + task.launchSettings().harness + " \u00b7 " + task.effort + " \u00b7 " +
task.permissionMode
));
driver.click(session.parameters.header());
driver.settle();
CHECK(session.parameters.isExpanded());
CHECK(session.model.isVisible());
CHECK(session.model.windowRect().pos.y < session.startSession.windowRect().pos.y);
}
TEST(ParametersSaveAsANamedPresetAndApplyToAnotherTask) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Session);
scene.view.openTask(scene.release);
driver.settle();
SessionTab& session = scene.view.session();
session.parameters.setExpanded(true, false);
driver.settle();
const std::vector<std::string>& efforts = session.effort.options();
session.effort.setSelectedIndex(
long(std::find(efforts.begin(), efforts.end(), std::string("low")) - efforts.begin())
);
CHECK_EQUAL("low", scene.board.task(scene.release)->effort);
CHECK_EQUAL(SessionPresetPicker::kCustomOption, session.presets.preset.selectedText());
driver.click(session.presets.saveAs);
driver.settle();
REQUIRE CHECK(session.presets.isNaming());
driver.click(session.presets.name);
driver.type("dumb agent");
driver.key(InputID::ENTER);
driver.settle();
CHECK(!session.presets.isNaming());
CHECK_EQUAL("dumb agent", session.presets.preset.selectedText());
const std::string path = SessionPresets::pathIn(scene.storage.root());
CHECK_EQUAL("/home/user/taskboard/session-presets.json", path);
const std::optional<std::string> stored = scene.files.readText(path);
REQUIRE CHECK(stored.has_value());
CHECK(stored->find("\"dumb agent\"") != std::string::npos);
CHECK(stored->find("\"low\"") != std::string::npos);
scene.view.openTask(scene.widgets);
driver.settle();
CHECK_EQUAL(SessionPresetPicker::kCustomOption, session.presets.preset.selectedText());
CHECK(scene.board.task(scene.widgets)->effort != "low");
session.presets.preset.setSelectedIndex(1);
CHECK_EQUAL("low", scene.board.task(scene.widgets)->effort);
CHECK_EQUAL("low", session.effort.selectedText());
CHECK(scene.view.header().title.text().size() > 0);
SessionPresets reloaded(scene.files, scene.storage.root());
REQUIRE CHECK_EQUAL(1u, reloaded.all().size());
CHECK_EQUAL("dumb agent", reloaded.all()[0].name);
CHECK_EQUAL("low", reloaded.all()[0].settings.effort);
driver.click(session.presets.remove);
driver.settle();
CHECK_EQUAL(SessionPresetPicker::kCustomOption, session.presets.preset.selectedText());
reloaded.reload();
CHECK(reloaded.all().empty());
}
TEST(ZoomingIntoATaskCardFillsTheGraphWithTheSameCardView) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
driver.settle();
TreeView& graph = scene.view.graph();
graph.select(scene.release);
graph.setView(graph.pan(), graph.fullViewScale(), false);
graph.centerOn(scene.release, false);
driver.settle();
driver.moveTo(graph.windowPosition() + graph.size() / 2.0f);
for (int notch = 0; !graph.fullViewItem() && notch < 40; notch++)
driver.wheel(1);
driver.settle();
REQUIRE CHECK(graph.fullViewItem() == std::optional<NodeId>(scene.release));
TaskDescriptionPanel& description = scene.view.description();
CHECK(shownOnScreen(description.editor));
CHECK(description.windowRect().size.x > graph.size().x * 0.9f);
CHECK_EQUAL(scene.board.task(scene.release)->title, description.header.title.text());
CHECK(!shownOnScreen(*graph.card(scene.release)));
CHECK(scene.view.currentTask() == std::optional<NodeId>(scene.release));
driver.click(scene.view.header().back);
driver.settle();
CHECK(!graph.fullViewItem());
CHECK(description.windowRect().size.x < graph.size().x * 0.5f);
CHECK(shownOnScreen(*graph.card(scene.release)));
}
TEST(ZoomingMovesTheOneTaskWidgetIntoTheZoomAndCollapsesItsDockPanelUntilZoomingOut) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
TaskWidget& widget = scene.view.taskWidget();
REQUIRE CHECK(scene.view.taskPanel().isOpen());
CHECK(widget.isSelfOrAncestorOf(*scene.view.taskPanel().content()) || scene.view.taskPanel().content() == &widget);
graph.select(scene.release);
graph.enterFullView(scene.release);
driver.settle();
CHECK(!scene.view.taskPanel().isOpen());
CHECK(scene.view.zoomFrame() == &graph.fullViewFrame());
CHECK(graph.fullViewFrame().isSelfOrAncestorOf(widget));
CHECK(shownOnScreen(widget.carousel));
CHECK(widget.windowRect().size.x > graph.size().x * 0.9f);
graph.leaveFullView(false);
driver.settle();
CHECK(scene.view.taskPanel().isOpen());
CHECK(scene.view.zoomFrame() == nullptr);
CHECK(scene.view.taskPanel().isSelfOrAncestorOf(widget));
CHECK(shownOnScreen(widget.carousel));
}
TEST(ZoomingOutKeepsTheDockPanelClosedWhenItWasClosedBeforeZooming) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.dock().hidePanel(scene.view.taskPanel());
driver.settle();
TreeView& graph = scene.view.graph();
graph.select(scene.release);
graph.enterFullView(scene.release);
driver.settle();
CHECK(scene.view.taskWidget().carousel.isVisible());
CHECK(shownOnScreen(scene.view.taskWidget().carousel));
graph.leaveFullView(false);
driver.settle();
CHECK(!scene.view.taskPanel().isOpen());
}
TEST(OpeningTheTaskPanelFromItsStripeWhileZoomedLeavesTheZoom) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
graph.select(scene.release);
graph.enterFullView(scene.release);
driver.settle();
StripeButton* stripe = scene.view.dock().stripeButton(scene.view.taskPanel());
REQUIRE CHECK(stripe != nullptr);
REQUIRE CHECK(shownOnScreen(*stripe));
driver.click(*stripe);
driver.settle();
CHECK(!graph.fullViewItem());
CHECK(scene.view.zoomFrame() == nullptr);
CHECK(scene.view.taskPanel().isOpen());
CHECK(scene.view.taskPanel().isSelfOrAncestorOf(scene.view.taskWidget()));
CHECK(shownOnScreen(scene.view.taskWidget().carousel));
}
TEST(SwitchingMainViewWhileZoomedReturnsTheTaskWidgetToTheDock) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
graph.select(scene.release);
graph.enterFullView(scene.release);
driver.settle();
REQUIRE CHECK(!scene.view.taskPanel().isOpen());
driver.click(scene.view.views().tabButton(1));
driver.settle();
CHECK(scene.view.zoomFrame() == nullptr);
CHECK(scene.view.taskPanel().isOpen());
CHECK(scene.view.taskPanel().isSelfOrAncestorOf(scene.view.taskWidget()));
}
TEST(EscapeZoomsOutOfTheFullViewUnlessItsTitleOrEditorHasFocus) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
driver.settle();
TreeView& graph = scene.view.graph();
graph.select(scene.release);
graph.enterFullView(scene.release);
driver.settle();
TaskDescriptionPanel& description = scene.view.description();
description.editor.setMode(MarkdownEditor::Mode::Editor);
driver.settle();
driver.click(description.header.title);
driver.key(InputID::ESCAPE);
driver.settle();
CHECK(graph.fullViewItem() == std::optional<NodeId>(scene.release));
driver.click(description.editor.sourceArea());
driver.key(InputID::ESCAPE);
driver.settle();
CHECK(graph.fullViewItem() == std::optional<NodeId>(scene.release));
driver.ui().setKeyboardFocus(&graph.fullViewFrame());
REQUIRE CHECK(driver.ui().keyboardFocus() == &graph.fullViewFrame());
driver.key(InputID::ESCAPE);
driver.settle();
CHECK(!graph.fullViewItem());
}
TEST(SavingUnderAnExistingNameReplacesThatPreset) {
ui::testing::MemoryFileSystem files("/home/user");
SessionPresets presets(files, "/home/user/taskboard");
presets.save("smart", { "claude-opus-5-5", "claude-code", "high", "auto" });
presets.save("dumb", { "claude-haiku", "claude-code", "low", "auto" });
presets.save("smart", { "claude-opus-5-5", "dsh", "max", "auto" });
REQUIRE CHECK_EQUAL(2u, presets.all().size());
CHECK_EQUAL("max", presets.all()[0].settings.effort);
CHECK(presets.matching({ "claude-haiku", "claude-code", "low", "auto" }) == std::optional<size_t>(1));
CHECK(!presets.lastSaveFailed());
}
TEST(ChangedSettingsOfferRestart) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Session);
Task& task = *scene.board.task(scene.release);
task.session = { SessionState::Idle, "", task.launchSettings() };
scene.view.openTask(scene.release);
driver.settle();
SessionTab& session = scene.view.session();
CHECK(!session.isOfferingRestart());
session.model.setSelectedIndex(1);
driver.settle();
CHECK_EQUAL("claude-opus-4-8", task.agentModel);
CHECK(session.isOfferingRestart());
CHECK(session.restartHint.text().find("claude-opus-5-5") != std::string::npos);
scene.view.sessionScroll().jumpTo(scene.view.sessionScroll().maximumOffset());
driver.settle();
driver.click(session.restartKeep);
driver.click(session.restartFresh);
CHECK(
scene.store.restarts ==
(std::vector<std::pair<NodeId, bool>>{ { scene.release, false }, { scene.release, true } })
);
}
TEST(OpenSessionGoesToCallbackForCurrentTask) {
TreeScene scene;
UiDriver driver(scene.window, { 1200, 900 });
std::vector<NodeId> opened;
scene.view.setOnOpenSession([&](NodeId id) { opened.push_back(id); });
driver.key(InputID::O, { .control = true });
CHECK(opened.empty());
scene.view.openTask(scene.emoji);
CHECK_EQUAL(0u, driver.ui().openPopupCount());
driver.key(InputID::O, { .control = true });
CHECK(opened == std::vector<NodeId>{ scene.emoji });
}
}
namespace {
halnf length(Vec2F vector) { return std::sqrt(vector.x * vector.x + vector.y * vector.y); }
}
SUITE(TaskTreeGraph) {
Vec2F cardPoint(TreeScene & scene, NodeId id) {
TreeView& graph = scene.view.graph();
const RectF card = *graph.cardRect(id);
return graph.windowPosition() + card.pos + card.size / 2.0f;
}
Vec2F graphMiddle(TreeScene & scene) {
TreeView& graph = scene.view.graph();
return graph.windowPosition() + graph.size() / 2.0f;
}
bool isShown(TreeScene & scene, NodeId id) { return scene.view.graph().stateOf(id) == TreeItemState::Shown; }
void showEverything(UiDriver & driver, TreeView & graph, NodeId around) {
graph.openTo(std::nullopt, graph.structure().height());
graph.select(around);
driver.settle();
}
TEST(GraphHoldsTheWholeBoardFramedWhenTheWindowOpens) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
CHECK_EQUAL(4u, graph.structure().items().size());
size_t shown = 0;
for (const TreeStructure::Item& item : graph.structure().items()) {
if (!isShown(scene, NodeId(item.id))) continue;
shown++;
const RectF card = *graph.cardRect(item.id);
CHECK(contains(graph.localRect(), card.pos));
CHECK(contains(graph.localRect(), card.pos + card.size));
}
CHECK(shown >= 2);
}
TEST(BoardLoadedAfterTheWindowOpensIsShown) {
TaskBoard board;
ApplicationFrame window;
TaskTreeView view{ board };
window.setContent(&view);
view.attachMenus(window.menuBar, [] {});
UiDriver driver(window, { 1400, 900 });
driver.settle();
const NodeId root = board.addTask({ .title = "Root" }, { 0, 0 });
for (int index = 0; index < 4; index++) {
const NodeId child = board.addTask({ .title = "Child" }, { 0, 0 });
board.setParent(child, root);
}
view.boardReloaded();
driver.settle();
TreeView& graph = view.graph();
CHECK_EQUAL(5u, graph.structure().items().size());
const RectF card = *graph.cardRect(root);
CHECK(contains(graph.localRect(), card.pos + card.size / 2.0f));
}
TEST(OpeningATaskSelectsAndCentresItWithoutReRooting) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.openTask(scene.emoji);
driver.settle();
TreeView& graph = scene.view.graph();
CHECK_EQUAL(4u, graph.structure().items().size());
CHECK(graph.selection() == scene.emoji);
CHECK(isShown(scene, scene.emoji));
CHECK(length(cardPoint(scene, scene.emoji) - graphMiddle(scene)) < 1);
}
TEST(TapSelectsAndShowsPanelsSecondTapOpensAndClosesNoDoubleTapFocus) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.widgets);
graph.select(std::nullopt);
driver.settle();
driver.click(cardPoint(scene, scene.textEdit));
driver.settle();
CHECK(scene.view.currentTask() == scene.textEdit);
CHECK(graph.selection() == scene.textEdit);
CHECK(length(cardPoint(scene, scene.textEdit) - graphMiddle(scene)) < 1);
CHECK(graph.isOpen(scene.textEdit));
driver.click(cardPoint(scene, scene.textEdit));
driver.settle();
CHECK(!graph.isOpen(scene.textEdit));
CHECK(!isShown(scene, scene.emoji));
CHECK(scene.view.currentTask() == scene.textEdit);
CHECK_EQUAL(4u, graph.structure().items().size());
driver.click(cardPoint(scene, scene.textEdit));
driver.settle();
CHECK(graph.isOpen(scene.textEdit));
CHECK(isShown(scene, scene.emoji));
}
TEST(HorizontalWheelOpensOnlyTheSelectedTasksChildrenVerticalOnlyZooms) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
graph.openTo(std::nullopt, 0);
graph.select(std::nullopt);
graph.frame(false);
driver.settle();
driver.click(cardPoint(scene, scene.widgets));
driver.settle();
driver.moveTo(graphMiddle(scene));
const halnf scale = graph.targetScale();
for (int notch = 0; notch < 40 && !graph.isOpen(scene.widgets); notch++)
driver.horizontalWheel(1);
driver.settle();
CHECK(graph.isOpen(scene.widgets));
CHECK(isShown(scene, scene.textEdit));
CHECK(!isShown(scene, scene.emoji));
CHECK(scene.view.currentTask() == scene.widgets);
CHECK_CLOSE(scale, graph.targetScale(), 0.0001f);
for (int notch = 0; notch < 8; notch++)
driver.wheel(1);
driver.settle();
CHECK(graph.targetScale() > scale);
CHECK(!graph.isOpen(scene.textEdit));
}
TEST(ArrowKeysMoveTheSelectionAfterClickingANode) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.widgets);
driver.click(cardPoint(scene, scene.widgets));
driver.settle();
driver.key(InputID::RIGHT);
driver.settle();
CHECK(graph.selection() == scene.textEdit);
CHECK(scene.view.currentTask() == scene.textEdit);
driver.key(InputID::LEFT);
driver.settle();
CHECK(graph.selection() == scene.widgets);
}
TEST(GraphPlacesTasksAsATreeAndDragDoesNotMoveThem) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.textEdit);
const RectF widgets = *graph.cardRect(scene.widgets);
const RectF textEdit = *graph.cardRect(scene.textEdit);
const RectF emoji = *graph.cardRect(scene.emoji);
CHECK(textEdit.pos.x > widgets.pos.x + widgets.size.x);
CHECK(emoji.pos.x > textEdit.pos.x + textEdit.size.x);
const Vec2F point = cardPoint(scene, scene.textEdit);
driver.drag(point, point + Vec2F(60.0f, 40.0f), 4);
driver.settle();
CHECK_CLOSE(textEdit.pos.x, graph.cardRect(scene.textEdit)->pos.x, 0.01f);
CHECK_CLOSE(textEdit.pos.y, graph.cardRect(scene.textEdit)->pos.y, 0.01f);
}
TEST(DraggingFromChildrenPortCreatesAChildAndFocusesItForEditing) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.release);
const size_t before = scene.board.graph().nodes().size();
const Vec2F port = graph.windowPosition() + *graph.portPosition(scene.release, TreeView::Port::Children);
driver.drag(port, port + Vec2F(160.0f, 200.0f), 6);
driver.settle();
REQUIRE CHECK_EQUAL(before + 1, scene.board.graph().nodes().size());
const NodeId created = scene.board.graph().nodes().back().id;
CHECK(scene.board.parentOf(created) == scene.release);
CHECK(isShown(scene, created));
CHECK(graph.selection() == created);
CHECK(length(cardPoint(scene, created) - graphMiddle(scene)) < 1);
CHECK(scene.view.currentTask() == created);
CHECK(scene.view.session().isVisible());
CHECK(scene.view.header().title.isEditing());
}
TEST(DeletingAChildFocusesItsParentWithoutReframing) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.textEdit);
scene.view.openTask(scene.textEdit);
driver.settle();
const halnf scaleBefore = graph.targetScale();
scene.chooseAction(driver, "Delete task...");
driver.settle();
REQUIRE CHECK(scene.view.header().deleteConfirm.isOpen());
scene.view.header().deleteConfirm.accept();
driver.settle();
CHECK(scene.board.task(scene.textEdit) == nullptr);
CHECK(scene.view.currentTask() == scene.widgets);
CHECK(graph.selection() == scene.widgets);
CHECK_CLOSE(scaleBefore, graph.targetScale(), 0.0001f);
}
TEST(DeletingAnUnsavedGraphTaskRemovesItsCardImmediately) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.release);
const Vec2F port = graph.windowPosition() + *graph.portPosition(scene.release, TreeView::Port::Children);
driver.drag(port, port + Vec2F(160.0f, 200.0f), 6);
driver.settle();
const std::optional<NodeId> created = scene.view.currentTask();
REQUIRE CHECK(created.has_value());
CHECK(graph.structure().indexOf(*created).has_value());
scene.chooseAction(driver, "Delete task...");
driver.settle();
REQUIRE CHECK(scene.view.header().deleteConfirm.isOpen());
scene.view.header().deleteConfirm.accept();
driver.settle();
CHECK(scene.board.task(*created) == nullptr);
CHECK(!graph.structure().indexOf(*created).has_value());
CHECK(scene.view.currentTask() == scene.release);
}
TEST(DraggingATaskOntoItsOwnSubtreeIsRefused) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.textEdit);
const Vec2F port = graph.windowPosition() + *graph.portPosition(scene.widgets, TreeView::Port::Parent);
driver.drag(port, cardPoint(scene, scene.emoji), 6);
driver.settle();
CHECK(!scene.board.parentOf(scene.widgets));
CHECK(scene.board.parentOf(scene.emoji) == scene.textEdit);
}
TEST(ReparentingByDraggingRearrangesTheTree) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.textEdit);
const Vec2F port = graph.windowPosition() + *graph.portPosition(scene.emoji, TreeView::Port::Parent);
driver.drag(port, cardPoint(scene, scene.release), 6);
driver.settle();
CHECK(scene.board.parentOf(scene.emoji) == scene.release);
const RectF release = *graph.cardRect(scene.release);
const RectF emoji = *graph.cardRect(scene.emoji);
CHECK(emoji.pos.x > release.pos.x + release.size.x);
CHECK_CLOSE(release.center().y, emoji.center().y, 0.5f);
}
TEST(DeletingKeepsTheTreeFocusedSoArrowsKeepWorking) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
showEverything(driver, graph, scene.emoji);
scene.view.openTask(scene.emoji);
driver.settle();
driver.ui().setKeyboardFocus(&graph);
REQUIRE CHECK(graph.selection() == scene.emoji);
driver.key(InputID::DELETE_KEY);
driver.settle();
REQUIRE CHECK(scene.view.header().deleteConfirm.isOpen());
scene.view.header().deleteConfirm.accept();
driver.settle();
REQUIRE CHECK(scene.board.task(scene.emoji) == nullptr);
CHECK(driver.ui().keyboardFocus() == &graph);
REQUIRE CHECK(graph.selection() == scene.textEdit);
driver.key(InputID::LEFT);
driver.settle();
CHECK(graph.selection() == scene.widgets);
}
TEST(DeleteKeyRemovesEverySelectedTaskAfterConfirming) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
const NodeId spare = scene.board.addTask({ .title = "Spare" }, { 0, 0 });
scene.board.setParent(spare, scene.emoji);
scene.view.boardReloaded();
showEverything(driver, graph, scene.widgets);
driver.click(cardPoint(scene, scene.textEdit));
driver.settle();
driver.click(cardPoint(scene, scene.release), { .control = true });
driver.settle();
REQUIRE CHECK_EQUAL(2u, graph.selectedItems().size());
driver.key(InputID::DELETE_KEY);
driver.settle();
REQUIRE CHECK(scene.view.graphDeleteConfirm().isOpen());
scene.view.graphDeleteConfirm().accept();
driver.settle();
CHECK(scene.board.task(scene.textEdit) == nullptr);
CHECK(scene.board.task(scene.release) == nullptr);
CHECK(scene.board.parentOf(scene.emoji) == scene.widgets);
CHECK(scene.board.parentOf(spare) == scene.emoji);
CHECK_EQUAL(3u, scene.board.graph().nodes().size());
CHECK(!graph.selection());
CHECK(!scene.view.currentTask());
}
TEST(DeleteKeyOnOneSelectedTaskUsesThePageConfirmation) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
driver.click(cardPoint(scene, scene.release));
driver.settle();
driver.key(InputID::DELETE_KEY);
driver.settle();
CHECK(scene.view.header().deleteConfirm.isOpen());
CHECK(scene.view.currentTask() == scene.release);
}
TEST(DetailedTreeCardsShowTheSharedTaskCardWithoutAHeader) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.board.task(scene.release)->session.state = SessionState::Working;
scene.board.task(scene.release)->description = "Collect the changes";
scene.view.boardReloaded();
driver.settle();
TreeView& graph = scene.view.graph();
graph.setView(graph.pan(), 1, false);
graph.select(scene.release);
driver.settle();
auto* card = dynamic_cast<TaskTreeCard*>(graph.card(scene.release));
REQUIRE CHECK(card != nullptr);
CHECK(!card->isHeaderShown());
CHECK(card->content().isVisible());
TaskCardView& view = card->view();
CHECK_EQUAL("#4", view.number.text());
CHECK_EQUAL("Release notes", view.title.text());
CHECK_EQUAL("Collect the changes", view.description.text());
CHECK_EQUAL(std::string(statusLabel(scene.board.task(scene.release)->status)), view.status.text());
CHECK(view.session.isVisible());
CHECK(view.session.isPulsing());
CHECK(view.session.isDotShown());
CHECK(!view.status.isDotShown());
CHECK(view.title.windowRect().pos.y > view.number.windowRect().pos.y);
CHECK(view.description.windowRect().pos.y > view.title.windowRect().pos.y);
CHECK(view.description.wraps());
}
TEST(TreeAndKanbanCardsShareOneTaskCardAndSize) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.board.task(scene.release)->description = std::string(600, 'x') + " tail";
scene.board.task(scene.release)->status = TaskStatus::Queued;
scene.view.boardReloaded();
driver.settle();
TreeView& graph = scene.view.graph();
graph.setView(graph.pan(), 1, false);
driver.settle();
const RectF treeCard = *graph.cardRect(scene.release);
driver.click(scene.view.views().tabButton(1));
driver.settle();
auto* kanbanCard = dynamic_cast<TaskKanbanCard*>(scene.view.kanban().card(scene.release));
REQUIRE CHECK(kanbanCard != nullptr);
CHECK_CLOSE(treeCard.size.y, kanbanCard->windowRect().size.y, 1.0f);
TaskCardView& view = kanbanCard->view();
CHECK_EQUAL("Release notes", view.title.text());
CHECK(!view.status.isVisible());
const RectF description = view.description.windowRect();
const RectF cardRect = kanbanCard->windowRect();
CHECK(description.pos.y + description.size.y <= cardRect.pos.y + cardRect.size.y + 0.5f);
}
TEST(OneTaskPanelOnTheRightHoldsEveryTaskPageAndThePathRunsAcrossTop) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.openTask(scene.release);
driver.settle();
const RectF graph = scene.view.graph().windowRect();
const RectF widget = scene.view.taskWidget().windowRect();
const RectF path = scene.view.breadcrumb().windowRect();
const RectF view = scene.view.windowRect();
CHECK(scene.view.taskPanel().side() == DockSide::Right);
CHECK_EQUAL(1u, scene.view.dock().panels().size());
CHECK(widget.pos.x >= graph.pos.x + graph.size.x - 1);
CHECK(path.pos.y >= graph.pos.y + graph.size.y - 1);
CHECK(path.pos.y + path.size.y <= scene.view.status.windowRect().pos.y + 1);
CHECK(path.size.x > view.size.x - 40);
CHECK(scene.window.menuBar.centerWidget() == &scene.view.search());
TabView& carousel = scene.view.taskWidget().carousel;
REQUIRE CHECK_EQUAL(4u, carousel.tabCount());
CHECK_EQUAL("Task", carousel.tabButton(0).text());
CHECK_EQUAL("Session", carousel.tabButton(1).text());
CHECK_EQUAL("Workdir", carousel.tabButton(2).text());
CHECK_EQUAL("Links", carousel.tabButton(3).text());
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
scene.view.openTask(std::nullopt);
driver.settle();
CHECK(!shownOnScreen(scene.view.description().editor));
CHECK(driver.paint().hasText("Open a task to see and edit its description."));
}
}
SUITE(TaskDescriptionPanel) {
TEST(DescriptionIsAMarkdownPreviewFillingThePanelUnderAnIdAndTitleRow) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
scene.board.task(scene.release)->description = "# Plan\n\nShip **it**";
scene.view.openTask(scene.release);
driver.settle();
TaskDescriptionPanel& panel = scene.view.description();
CHECK(panel.editor.mode() == MarkdownEditor::Mode::Preview);
CHECK(shownOnScreen(panel.editor.preview()));
CHECK(!panel.editor.showsSource());
CHECK_EQUAL("#4", panel.header.idLabel.text());
CHECK_EQUAL("Release notes", panel.header.title.text());
CHECK(driver.paint().hasText("Plan"));
const RectF panelBody = scene.view.taskWidget().body().windowRect();
const RectF editor = panel.editor.windowRect();
CHECK(editor.pos.y > panel.header.title.windowRect().pos.y);
CHECK(editor.pos.y + editor.size.y > panelBody.pos.y + panelBody.size.y - 30);
CHECK(editor.size.x > panelBody.size.x - 40);
}
TEST(RenamingInTheDescriptionPanelEditsTheTaskAndReportsTheTitleScope) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
scene.view.openTask(scene.release);
driver.settle();
TaskTitleField& title = scene.view.header().title;
driver.click(title);
title.selectAll();
driver.type("Changelog");
REQUIRE CHECK(scene.store.editing.has_value());
CHECK(scene.store.editing->second == TaskField::Title);
CHECK_EQUAL("Changelog", scene.board.task(scene.release)->title);
CHECK_EQUAL("Changelog", scene.view.header().title.text());
CHECK(scene.edits > 0);
}
}
SUITE(TaskWorkspacePanel) {
struct WorkspaceScene : TreeScene {
UiDriver driver{ window, { 1400, 900 } };
WorkspaceScene() {
view.taskWidget().showPage(TaskWidgetPage::Workdir);
view.openTask(release);
driver.settle();
}
void open(const std::string& name) {
FileBrowser& browser = view.workspace().browser;
browser.selectOnly(name);
FileRow* row = browser.row(name);
REQUIRE CHECK(row != nullptr);
driver.advance(1000);
driver.click(*row);
driver.settle();
}
};
std::vector<std::string> listedNames(const FileBrowser& browser) {
std::vector<std::string> names;
for (const auto& row : browser.rows())
names.push_back(row->entry().name);
return names;
}
TEST(ListsTheTaskWorkspaceStartingAtItsRoot) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
CHECK_EQUAL("/agents/task-4", panel.browser.directory());
CHECK((listedNames(panel.browser) == std::vector<std::string>{ "notes", "reports" }));
CHECK(!panel.browser.up.isEnabled());
CHECK(shownOnScreen(panel.browser));
}
TEST(WorkdirHeaderAppearsOnlyForAnOpenedFile) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
CHECK(!shownOnScreen(panel.pages.currentPage().header()));
CHECK(shownOnScreen(panel.browser.path));
scene.open("reports");
scene.open("summary.md");
REQUIRE CHECK_EQUAL(2u, panel.pages.depth());
CHECK(shownOnScreen(panel.pages.currentPage().header()));
}
TEST(FoldersOpenInPlaceAndOnlyMarkdownFilesAreListed) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
scene.open("reports");
CHECK_EQUAL("/agents/task-4/reports", panel.browser.directory());
CHECK_EQUAL(1u, panel.pages.depth());
CHECK((listedNames(panel.browser) == std::vector<std::string>{ "summary.md" }));
scene.driver.click(panel.browser.up);
CHECK_EQUAL("/agents/task-4", panel.browser.directory());
CHECK(!panel.browser.goUp());
}
TEST(MarkdownOpensInAPreviewPageAndBackReturnsToTheFolder) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
scene.open("reports");
scene.open("summary.md");
CHECK_EQUAL(2u, panel.pages.depth());
CHECK_EQUAL("reports/summary.md", panel.pages.currentPage().title());
CHECK_EQUAL("/agents/task-4/reports/summary.md", panel.openedDocument());
CHECK(panel.document.mode() == MarkdownEditor::Mode::Preview);
CHECK(shownOnScreen(panel.document.preview()));
CHECK(scene.driver.paint().hasText("All done."));
scene.driver.click(panel.pages.currentPage().backButton());
scene.driver.settle();
CHECK_EQUAL(1u, panel.pages.depth());
CHECK(panel.openedDocument().empty());
CHECK_EQUAL("/agents/task-4/reports", panel.browser.directory());
CHECK(shownOnScreen(panel.browser));
}
TEST(EditingAnOpenedDocumentWritesItBack) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
panel.openDocument("/agents/task-4/reports/summary.md");
panel.document.setMode(MarkdownEditor::Mode::Editor);
scene.driver.settle();
scene.driver.click(panel.document.sourceArea());
scene.driver.type("!");
CHECK_EQUAL(panel.document.markdown(), *scene.files.readText("/agents/task-4/reports/summary.md"));
CHECK(scene.files.readText("/agents/task-4/reports/summary.md")->find('!') != std::string::npos);
}
TEST(OtherFileTypesAreNotOpened) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
panel.openDocument("/agents/task-4/reports/data.csv");
CHECK_EQUAL(1u, panel.pages.depth());
CHECK(panel.openedDocument().empty());
}
TEST(SwitchingTasksLeavesTheDocumentAndExplainsMissingWorkspaces) {
WorkspaceScene scene;
TaskWorkspacePanel& panel = scene.view.workspace();
panel.openDocument("/agents/task-4/reports/summary.md");
scene.view.openTask(scene.widgets);
scene.driver.settle();
CHECK_EQUAL(1u, panel.pages.depth());
CHECK(!shownOnScreen(panel.browser));
CHECK(shownOnScreen(panel.hint));
CHECK_EQUAL("This task has no workdir yet. It appears once its agent session starts.", panel.hint.text());
scene.view.openTask(std::nullopt);
scene.driver.settle();
CHECK(shownOnScreen(panel.hint));
CHECK_EQUAL("Open a task to browse the files its agent produced.", panel.hint.text());
}
}
SUITE(TaskKanban) {
size_t statusColumn(TreeScene & scene, TaskStatus status) { return scene.view.kanbanColumn(status); }
void showKanbanTab(UiDriver & driver, TreeScene & scene) {
driver.click(scene.view.views().tabButton(1));
driver.settle();
}
void showKanban(UiDriver & driver, TreeScene & scene) {
scene.view.kanban().setColumnVisible(statusColumn(scene, TaskStatus::Backlog), true);
showKanbanTab(driver, scene);
}
std::vector<KanbanItemId> cardsIn(TreeScene & scene, TaskStatus status) {
std::vector<KanbanItemId> ids;
for (const KanbanCard* card : scene.view.kanban().columnView(statusColumn(scene, status)).stack().cards())
ids.push_back(card->id());
return ids;
}
std::vector<std::string> shownColumnTitles(KanbanBoard & kanban) {
std::vector<std::string> titles;
for (size_t column = 0; column < kanban.columnCount(); column++)
if (shownOnScreen(kanban.columnView(column))) titles.push_back(kanban.columnView(column).title().text());
return titles;
}
TEST(DoubleClickingAKanbanCardOpensItsDescriptionFullViewAndBackReturns) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
driver.settle();
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
KanbanCard& card = *kanban.card(scene.release);
scene.view.dock().hidePanel(scene.view.taskPanel());
driver.settle();
REQUIRE CHECK(!scene.view.taskPanel().isOpen());
driver.click(card);
driver.click(card);
driver.settle();
REQUIRE CHECK(kanban.fullViewItem() == std::optional<KanbanItemId>(scene.release));
CHECK(!scene.view.taskPanel().isOpen());
TaskDescriptionPanel& description = scene.view.description();
CHECK(shownOnScreen(description.editor));
CHECK_EQUAL(scene.board.task(scene.release)->title, description.header.title.text());
CHECK(scene.view.currentTask() == std::optional<NodeId>(scene.release));
CHECK(!shownOnScreen(kanban.columnView(0)));
driver.click(scene.view.header().back);
driver.settle();
CHECK(!kanban.fullViewItem());
CHECK(!shownOnScreen(description));
CHECK(shownOnScreen(card));
}
TEST(DockedPanelShowsTheSameBadgeRowAboveTheFramedTaskBody) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.openTask(scene.release);
driver.settle();
TaskWidget& widget = scene.view.taskWidget();
REQUIRE CHECK(scene.view.taskPanel().isSelfOrAncestorOf(widget));
const RectF badges = scene.view.header().badgeRow().windowRect();
const RectF body = widget.body().windowRect();
CHECK(shownOnScreen(scene.view.header().idLabel));
CHECK(!shownOnScreen(scene.view.header().back));
CHECK(badges.pos.y + badges.size.y <= body.pos.y - 4);
CHECK(widget.carousel.tabButton(0).windowRect().pos.y >= body.pos.y + 4);
const RGBA surface = driver.ui().theme().palette.surface;
bool bodyPainted = false;
for (const PaintCommand& command : driver.paint().commands())
bodyPainted |= command.kind == PaintCommand::Kind::FillRect && command.color == surface &&
std::abs(command.rect.size.x - body.size.x) < 1 && std::abs(command.rect.size.y - body.size.y) < 1;
CHECK(bodyPainted);
}
TEST(BadgeRowSitsAboveTheFramedTaskBodyInsideTheFullView) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
kanban.enterFullView(scene.release, false);
driver.settle();
TaskWidget& widget = scene.view.taskWidget();
const RectF frame = kanban.fullViewFrame().windowRect();
const RectF content = widget.windowRect();
CHECK(content.pos.x - frame.pos.x >= FullViewFrame::kAccentInset + FullViewFrame::kAccentWidth + 4);
CHECK(content.pos.y - frame.pos.y >= 4);
CHECK(frame.pos.x + frame.size.x - (content.pos.x + content.size.x) >= 4);
const RectF badges = scene.view.header().badgeRow().windowRect();
const RectF body = widget.body().windowRect();
CHECK(scene.view.header().badgeRow().parent() == &widget);
CHECK(widget.carousel.parent() == &widget.body());
CHECK(badges.pos.y + badges.size.y <= body.pos.y - 4);
CHECK(widget.carousel.tabButton(0).windowRect().pos.y >= body.pos.y + 4);
const RectF previous = widget.carousel.previousButton().windowRect();
CHECK(previous.pos.x - content.pos.x >= 4);
}
TEST(FullViewIsTheGrownCardWithTheSameBadgeRowAndBack) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
const RGBA color = kTaskColors[2];
scene.board.task(scene.release)->color = color;
scene.view.boardReloaded();
TaskHeader& header = scene.view.header();
Widget* const badgeRowParent = header.badgeRow().parent();
CHECK(!header.back.isVisible());
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
kanban.enterFullView(scene.release, false);
driver.settle();
FullViewFrame& frame = kanban.fullViewFrame();
REQUIRE CHECK(frame.accent().has_value());
CHECK(*frame.accent() == color);
CHECK(frame.content() == &scene.view.taskWidget());
auto* preview = dynamic_cast<TaskCardView*>(frame.preview());
REQUIRE CHECK(preview != nullptr);
CHECK_EQUAL(scene.board.task(scene.release)->title, preview->title.text());
CHECK(header.badgeRow().parent() == badgeRowParent);
CHECK(shownOnScreen(header.back));
CHECK_EQUAL("\u2190 Back", header.back.text());
CHECK(shownOnScreen(header.idLabel));
CHECK_CLOSE(header.back.windowRect().center().y, header.idLabel.windowRect().center().y, 2.0f);
CHECK(header.idLabel.windowRect().pos.x > header.back.windowRect().pos.x);
scene.view.taskWidget().showPage(TaskWidgetPage::Links);
driver.settle();
CHECK(shownOnScreen(header.idLabel));
CHECK(shownOnScreen(header.back));
driver.click(header.back);
driver.settle();
CHECK(!kanban.fullViewItem());
CHECK(!header.back.isVisible());
CHECK(header.badgeRow().parent() == badgeRowParent);
}
TEST(KanbanDeleteKeyAsksLikeTheTreeAndArrowsMoveTheSelection) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
const size_t column = statusColumn(scene, scene.board.task(scene.release)->status);
const std::vector<KanbanCard*>& cards = kanban.columnView(column).stack().cards();
REQUIRE CHECK(cards.size() >= 2);
const NodeId first = cards[0]->id();
const NodeId second = cards[1]->id();
driver.click(*kanban.card(first));
driver.settle();
CHECK(driver.ui().keyboardFocus() == &kanban);
driver.key(InputID::DOWN);
driver.settle();
CHECK(kanban.selection() == std::optional<KanbanItemId>(second));
CHECK(scene.view.currentTask() == std::optional<NodeId>(second));
driver.key(InputID::DELETE_KEY);
driver.settle();
REQUIRE CHECK(scene.view.header().deleteConfirm.isOpen());
scene.view.header().deleteConfirm.accept();
driver.settle();
CHECK(scene.board.task(second) == nullptr);
CHECK(driver.ui().keyboardFocus() == &kanban);
}
TEST(TreeAndKanbanAreTabsOfTheMainView) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1400, 900 });
REQUIRE CHECK_EQUAL(4u, scene.view.views().tabCount());
CHECK_EQUAL("Tree", scene.view.views().tabButton(0).text());
CHECK_EQUAL("Kanban", scene.view.views().tabButton(1).text());
CHECK_EQUAL("Skills", scene.view.views().tabButton(2).text());
CHECK_EQUAL("Secrets", scene.view.views().tabButton(3).text());
CHECK(shownOnScreen(scene.view.graph()));
CHECK(!shownOnScreen(scene.view.kanban()));
showKanban(driver, scene);
CHECK(shownOnScreen(scene.view.kanban()));
CHECK(!shownOnScreen(scene.view.graph()));
}
TEST(ColumnsAreTaskStatusesInTheGlobalTaskOrder) {
TreeScene scene(false);
scene.board.task(scene.emoji)->status = TaskStatus::Wip;
scene.board.task(scene.widgets)->order = 3;
scene.board.task(scene.textEdit)->order = 1;
scene.board.task(scene.release)->order = 2;
scene.view.boardReloaded();
UiDriver driver(scene.window, { 1400, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
REQUIRE CHECK_EQUAL(kTaskStatuses.size(), kanban.columnCount());
CHECK_EQUAL("Backlog", kanban.columnView(statusColumn(scene, TaskStatus::Backlog)).title().text());
CHECK_EQUAL("Queued", kanban.columnView(statusColumn(scene, TaskStatus::Queued)).title().text());
CHECK_EQUAL("WIP", kanban.columnView(statusColumn(scene, TaskStatus::Wip)).title().text());
CHECK_EQUAL(statusColumn(scene, TaskStatus::Wip), *kanban.columnOf(scene.emoji));
CHECK(
(cardsIn(scene, TaskStatus::Backlog) == std::vector<KanbanItemId>{ scene.textEdit, scene.release, scene.widgets })
);
for (size_t column = 0; column < kanban.columnCount(); column++)
CHECK(!kanban.columns()[column].accent.has_value());
CHECK_EQUAL("#2 Text edit glitches", kanban.card(scene.textEdit)->title());
CHECK_EQUAL("space and emoji", kanban.card(scene.textEdit)->detail());
CHECK_EQUAL("No description", kanban.card(scene.widgets)->detail());
}
TEST(RetiredStatusNamesInSavedColumnSettingsAreIgnored) {
MemoryFileSystem files;
files.addTextFile("/home/user/taskboard/kanban-columns.json", R"({"hidden": ["backlog", "ready", "failed"]})");
KanbanColumnSettings settings(files, "/home/user/taskboard");
CHECK((settings.hidden() == std::vector<TaskStatus>{ TaskStatus::Backlog }));
}
TEST(SavedColumnOrderIsCompletedWithStatusesItDoesNotName) {
MemoryFileSystem files;
files.addTextFile(
"/home/user/taskboard/kanban-columns.json", R"({"order": ["done", "running", "wip", "done", 7], "hidden": []})"
);
KanbanColumnSettings settings(files, "/home/user/taskboard");
std::vector<TaskStatus> expected = { TaskStatus::Done, TaskStatus::Wip };
for (const TaskStatus status : kTaskStatuses)
if (status != TaskStatus::Done && status != TaskStatus::Wip) expected.push_back(status);
CHECK((settings.order() == expected));
CHECK(settings.hidden().empty());
}
TEST(ColumnSettingsWithoutAnOrderKeepTheDefaultOrder) {
MemoryFileSystem files;
files.addTextFile("/home/user/taskboard/kanban-columns.json", R"({"hidden": ["done"]})");
KanbanColumnSettings settings(files, "/home/user/taskboard");
CHECK((settings.order() == KanbanColumnSettings::orderByDefault()));
CHECK((settings.hidden() == std::vector<TaskStatus>{ TaskStatus::Done }));
}
TEST(ColumnOrderSurvivesARestartOnDisk) {
const std::filesystem::path directory =
std::filesystem::temp_directory_path() / ("taskboard-columns-" + std::to_string(::getpid()));
std::filesystem::remove_all(directory);
{
KanbanColumnSettings settings(FileSystem::local(), directory.string());
settings.moveColumn(settings.order().size() - 1, 0);
settings.setVisible(TaskStatus::Blocked, true);
}
KanbanColumnSettings restarted(FileSystem::local(), directory.string());
CHECK(restarted.order().front() == TaskStatus::Blocked);
CHECK_EQUAL(kTaskStatuses.size(), restarted.order().size());
const std::vector<TaskStatus>& hidden = restarted.hidden();
CHECK(std::find(hidden.begin(), hidden.end(), TaskStatus::Blocked) == hidden.end());
std::filesystem::remove_all(directory);
}
TEST(BacklogAndRarelyUsedColumnsAreHiddenByDefault) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1400, 900 });
showKanbanTab(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
CHECK((shownColumnTitles(kanban) == std::vector<std::string>{ "Queued", "WIP", "Review", "Done" }));
CHECK(!shownOnScreen(*kanban.card(scene.release)));
}
TEST(ColumnMenuShowsAColumnAndTheChoiceIsRemembered) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1400, 900 });
showKanbanTab(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
KanbanColumnView& queued = kanban.columnView(statusColumn(scene, TaskStatus::Queued));
driver.click(queued.optionsButton());
driver.settle();
REQUIRE CHECK(queued.optionsMenu().isOpen());
const auto& items = queued.optionsMenu().items();
REQUIRE CHECK_EQUAL(kTaskStatuses.size(), items.size());
driver.click(*items[statusColumn(scene, TaskStatus::Backlog)]);
driver.click(queued.optionsButton());
driver.settle();
driver.click(*items[statusColumn(scene, TaskStatus::Done)]);
driver.settle();
CHECK((shownColumnTitles(kanban) == std::vector<std::string>{ "Backlog", "Queued", "WIP", "Review" }));
CHECK(shownOnScreen(*kanban.card(scene.release)));
TaskTreeView reopened{ scene.board, scene.files, scene.storage };
CHECK(reopened.kanban().isColumnVisible(reopened.kanbanColumn(TaskStatus::Backlog)));
CHECK(!reopened.kanban().isColumnVisible(reopened.kanbanColumn(TaskStatus::Done)));
CHECK(reopened.kanban().isColumnVisible(reopened.kanbanColumn(TaskStatus::Queued)));
}
TEST(DraggingAColumnHeaderMovesTheColumnAndRemembersTheOrder) {
TreeScene scene(false);
scene.board.task(scene.widgets)->status = TaskStatus::Wip;
scene.view.boardReloaded();
UiDriver driver(scene.window, { 1600, 900 });
showKanbanTab(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
const RectF review = kanban.columnView(statusColumn(scene, TaskStatus::Review)).windowRect();
const Vec2F reviewRightHalf = { review.pos.x + review.size.x * 0.75f, review.center().y };
driver.drag(driver.centerOf(kanban.columnView(statusColumn(scene, TaskStatus::Wip)).title()), reviewRightHalf, 10);
driver.settle();
CHECK((shownColumnTitles(kanban) == std::vector<std::string>{ "Queued", "Review", "WIP", "Done" }));
CHECK((cardsIn(scene, TaskStatus::Wip) == std::vector<KanbanItemId>{ scene.widgets }));
CHECK(scene.board.task(scene.widgets)->status == TaskStatus::Wip);
TaskTreeView reopened{ scene.board, scene.files, scene.storage };
CHECK(reopened.kanbanColumn(TaskStatus::Review) < reopened.kanbanColumn(TaskStatus::Wip));
CHECK_EQUAL("WIP", reopened.kanban().columnView(reopened.kanbanColumn(TaskStatus::Wip)).title().text());
CHECK(!reopened.kanban().isColumnVisible(reopened.kanbanColumn(TaskStatus::Backlog)));
}
TEST(ACardDroppedIntoAMovedColumnTakesThatColumnsStatus) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1600, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
kanban.moveColumn(statusColumn(scene, TaskStatus::Review), 0);
driver.settle();
REQUIRE CHECK_EQUAL(0u, statusColumn(scene, TaskStatus::Review));
driver.drag(driver.centerOf(*kanban.card(scene.release)), kanban.columnView(0).windowRect().center(), 10);
driver.settle();
CHECK(scene.board.task(scene.release)->status == TaskStatus::Review);
CHECK((cardsIn(scene, TaskStatus::Review) == std::vector<KanbanItemId>{ scene.release }));
}
TEST(DroppingACardBetweenTwoCardsPlacesItThereInTheGlobalOrder) {
TreeScene scene(false);
for (NodeId id : { scene.widgets, scene.textEdit, scene.emoji, scene.release })
scene.board.task(id)->status = TaskStatus::Queued;
scene.board.task(scene.widgets)->order = 0;
scene.board.task(scene.textEdit)->order = 1;
scene.board.task(scene.emoji)->order = 2;
scene.board.task(scene.release)->order = 3;
scene.view.boardReloaded();
UiDriver driver(scene.window, { 1600, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
const RectF target = kanban.card(scene.textEdit)->windowRect();
driver.drag(driver.centerOf(*kanban.card(scene.release)), { target.center().x, target.pos.y + 4 }, 10);
driver.settle();
const std::vector<KanbanItemId> expected = { scene.widgets, scene.release, scene.textEdit, scene.emoji };
CHECK((cardsIn(scene, TaskStatus::Queued) == expected));
CHECK((scene.board.tasksInOrder() == std::vector<NodeId>(expected.begin(), expected.end())));
CHECK_EQUAL(1, scene.edits);
}
TEST(TheKanbanOrderIsTheOrderOfChildrenInTheTree) {
TreeScene scene(false);
const NodeId second =
scene.board.addTask({ .title = "Second child", .status = TaskStatus::Queued, .order = 5 }, { 0, 0 });
scene.board.setParent(second, scene.widgets);
scene.board.task(scene.textEdit)->status = TaskStatus::Queued;
scene.board.task(scene.textEdit)->order = 4;
scene.view.boardReloaded();
UiDriver driver(scene.window, { 1600, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
REQUIRE CHECK((scene.board.childrenOf(scene.widgets) == std::vector<NodeId>{ scene.textEdit, second }));
const RectF target = kanban.card(scene.textEdit)->windowRect();
driver.drag(driver.centerOf(*kanban.card(second)), { target.center().x, target.pos.y + 4 }, 10);
driver.settle();
CHECK((scene.board.childrenOf(scene.widgets) == std::vector<NodeId>{ second, scene.textEdit }));
}
TEST(CardsHaveOneFixedHeightWhateverTheDescription) {
TreeScene scene(false);
scene.board.task(scene.emoji)->description = std::string(2000, 'x') + " long description";
scene.view.boardReloaded();
UiDriver driver(scene.window, { 1400, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
const halnf height = kanban.card(scene.widgets)->size().y;
CHECK(height > 60);
CHECK_CLOSE(height, kanban.card(scene.emoji)->size().y, 0.01f);
CHECK_CLOSE(height, kanban.card(scene.textEdit)->size().y, 0.01f);
}
TEST(DraggingACardToAnotherColumnChangesTheTaskStatus) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1600, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
const size_t review = statusColumn(scene, TaskStatus::Review);
driver.drag(driver.centerOf(*kanban.card(scene.release)), kanban.columnView(review).windowRect().center(), 10);
driver.settle();
CHECK(scene.board.task(scene.release)->status == TaskStatus::Review);
CHECK_EQUAL(review, *kanban.columnOf(scene.release));
CHECK_EQUAL(1, scene.edits);
}
TEST(ReadOnlyBoardPutsADraggedCardBack) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1600, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
const size_t original = *kanban.columnOf(scene.release);
scene.view.setReadOnly(true);
driver.drag(
driver.centerOf(*kanban.card(scene.release)),
kanban.columnView(statusColumn(scene, TaskStatus::Review)).windowRect().center(),
10
);
driver.settle();
CHECK(scene.board.task(scene.release)->status == TaskStatus::Backlog);
CHECK_EQUAL(original, *kanban.columnOf(scene.release));
CHECK_EQUAL(0, scene.edits);
}
TEST(StatusChangedElsewhereMovesTheCard) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1400, 900 });
showKanban(driver, scene);
scene.board.task(scene.widgets)->status = TaskStatus::Done;
scene.view.boardReloaded();
driver.settle();
CHECK_EQUAL(statusColumn(scene, TaskStatus::Done), *scene.view.kanban().columnOf(scene.widgets));
}
TEST(ClickingACardOpensTheTaskAndOpeningATaskSelectsItsCard) {
TreeScene scene;
UiDriver driver(scene.window, { 1600, 900 });
showKanban(driver, scene);
KanbanBoard& kanban = scene.view.kanban();
driver.click(*kanban.card(scene.emoji));
driver.settle();
CHECK(scene.view.currentTask() == scene.emoji);
CHECK(kanban.selection() == scene.emoji);
scene.view.openTask(scene.release);
driver.settle();
CHECK(kanban.selection() == scene.release);
CHECK(kanban.card(scene.release)->isSelected());
CHECK(!kanban.card(scene.emoji)->isSelected());
}
TEST(TaskSelectedInKanbanIsShownCentredWhenSwitchingToTree) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1600, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
graph.openTo(std::nullopt, 0);
driver.settle();
REQUIRE CHECK(graph.stateOf(scene.emoji) != TreeItemState::Shown);
showKanban(driver, scene);
driver.click(*scene.view.kanban().card(scene.emoji));
driver.settle();
driver.click(scene.view.views().tabButton(0));
driver.settle();
CHECK(graph.selection() == scene.emoji);
CHECK(graph.stateOf(scene.emoji) == TreeItemState::Shown);
const RectF card = *graph.cardRect(scene.emoji);
const Vec2F centre = card.pos + card.size / 2.0f;
CHECK_CLOSE(graph.size().x / 2, centre.x, 1.0f);
CHECK_CLOSE(graph.size().y / 2, centre.y, 1.0f);
}
TEST(SwitchingBackToTreeRecentresTheSelectedTaskAfterPanningAway) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1600, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
scene.view.openTask(scene.emoji);
driver.settle();
graph.setView(graph.pan() + Vec2F(400, 250), graph.scale(), false);
driver.settle();
showKanban(driver, scene);
driver.click(scene.view.views().tabButton(0));
driver.settle();
const RectF card = *graph.cardRect(scene.emoji);
const Vec2F centre = card.pos + card.size / 2.0f;
CHECK_CLOSE(graph.size().x / 2, centre.x, 1.0f);
CHECK_CLOSE(graph.size().y / 2, centre.y, 1.0f);
}
TEST(TaskSelectedInTreeIsSelectedInKanban) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1600, 900 });
driver.settle();
TreeView& graph = scene.view.graph();
graph.openTo(std::nullopt, graph.structure().height());
driver.settle();
const RectF card = *graph.cardRect(scene.textEdit);
driver.click(graph.windowPosition() + card.pos + card.size / 2.0f);
driver.settle();
showKanban(driver, scene);
CHECK(scene.view.kanban().selection() == scene.textEdit);
CHECK(scene.view.kanban().card(scene.textEdit)->isSelected());
}
TEST(DeletedTaskLeavesTheKanban) {
TreeScene scene(false);
UiDriver driver(scene.window, { 1400, 900 });
showKanban(driver, scene);
scene.board.graph().removeNode(scene.release);
scene.view.boardReloaded();
driver.settle();
CHECK(scene.view.kanban().card(scene.release) == nullptr);
}
}
SUITE(TaskTimeline) {
constexpr double kHour = 60 * 60;
constexpr double kDay = 24 * kHour;
TEST(SliderCoversOneWindowOfThePrecisionAndItsEndIsLive) {
TaskTimeline timeline;
std::vector<std::optional<double>> chosen;
timeline.setOnMomentChosen([&](std::optional<double> moment) { chosen.push_back(moment); });
CHECK(!timeline.slider.isEnabled());
const double last = 10 * kDay;
timeline.setSpan(last - 3 * kDay, last);
CHECK(timeline.slider.isEnabled());
CHECK_EQUAL("1 hour", std::string(timeline.precision().label));
CHECK_EQUAL(60u, timeline.stepCount());
CHECK_CLOSE(last - kHour, timeline.windowStart(), 0.001);
timeline.setPrecision(2);
CHECK_EQUAL(96u, timeline.stepCount());
CHECK_CLOSE(last - kDay, timeline.momentAtStep(0).value_or(0), 0.001);
CHECK_CLOSE(last - kDay / 2, timeline.momentAtStep(48).value_or(0), 0.001);
CHECK(!timeline.momentAtStep(96).has_value());
timeline.chooseStep(48);
timeline.chooseStep(96);
REQUIRE CHECK_EQUAL(2u, chosen.size());
CHECK_CLOSE(last - kDay / 2, chosen[0].value_or(0), 0.001);
CHECK(!chosen[1].has_value());
}
TEST(BackAndForwardShiftTheWindowByOnePrecision) {
TaskTimeline timeline;
std::optional<double> shown;
timeline.setOnMomentChosen([&](std::optional<double> moment) {
shown = moment;
timeline.showMoment(moment);
});
const double last = 10 * kDay;
timeline.setSpan(last - 5 * kHour - 10, last);
CHECK(!timeline.forward.isEnabled());
CHECK(timeline.back.isEnabled());
timeline.previousWindow();
CHECK_CLOSE(last - kHour, shown.value_or(0), 0.001);
CHECK_CLOSE(last - 2 * kHour, timeline.windowStart(), 0.001);
CHECK_EQUAL(60.0f, timeline.slider.value());
timeline.chooseStep(30);
CHECK_CLOSE(last - kHour - kHour / 2, shown.value_or(0), 0.001);
timeline.previousWindow();
CHECK_CLOSE(last - 2 * kHour - kHour / 2, shown.value_or(0), 0.001);
CHECK_CLOSE(last - 3 * kHour, timeline.windowStart(), 0.001);
CHECK_EQUAL(30.0f, timeline.slider.value());
CHECK(timeline.moment.text().find("read-only") != std::string::npos);
timeline.nextWindow();
CHECK_CLOSE(last - kHour - kHour / 2, shown.value_or(0), 0.001);
timeline.nextWindow();
CHECK_CLOSE(last - kHour / 2, shown.value_or(0), 0.001);
CHECK_CLOSE(last - kHour, timeline.windowStart(), 0.001);
CHECK(!timeline.forward.isEnabled());
timeline.previousWindow();
timeline.chooseStep(60);
timeline.nextWindow();
CHECK(!shown.has_value());
}
TEST(BackStopsAtTheOldestWindow) {
TaskTimeline timeline;
std::optional<double> shown;
timeline.setOnMomentChosen([&](std::optional<double> moment) {
shown = moment;
timeline.showMoment(moment);
});
const double last = 10 * kDay;
timeline.setSpan(last - kHour - 10, last);
timeline.previousWindow();
CHECK(!timeline.back.isEnabled());
timeline.previousWindow();
CHECK_CLOSE(last - kHour, shown.value_or(0), 0.001);
}
TEST(SwitchingBetweenLiveAndThePastKeepsTheSliderGeometry) {
ApplicationFrame window;
TaskTimeline timeline;
window.setContent(&timeline);
UiDriver driver(window, { 1200, 200 });
const double last = 10 * kDay;
timeline.setSpan(last - 5 * kDay, last);
driver.settle();
const RectF live = timeline.slider.windowRect();
timeline.showMoment(last - 2 * kDay - 1234);
driver.settle();
CHECK_CLOSE(live.pos.x, timeline.slider.windowRect().pos.x, 0.01f);
CHECK_CLOSE(live.size.x, timeline.slider.windowRect().size.x, 0.01f);
timeline.showMoment(std::nullopt);
timeline.setSpan(std::nullopt, std::nullopt);
driver.settle();
CHECK_CLOSE(live.size.x, timeline.slider.windowRect().size.x, 0.01f);
}
TEST(OnlyChangesInsideTheWindowBecomeTickMarks) {
TaskTimeline timeline;
const double last = 10 * kDay;
timeline.setSpan(last - 4 * kDay, last);
timeline.setPrecision(2);
timeline.setChangeMoments({ last - 2 * kDay, last - kDay, last - kDay / 2, last });
const std::vector<halnf>& ticks = timeline.slider.tickMarks();
REQUIRE CHECK_EQUAL(3u, ticks.size());
CHECK_CLOSE(0.0f, ticks[0], 0.0001f);
CHECK_CLOSE(0.5f, ticks[1], 0.0001f);
CHECK_CLOSE(1.0f, ticks[2], 0.0001f);
}
TEST(TimelineIsHiddenByDefaultAndToggledFromTheViewMenu) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
driver.settle();
TaskTimeline& timeline = scene.view.timeline();
CHECK(!timeline.isVisible());
auto toggle = [&] {
for (const auto& item : scene.view.viewMenu.items()) {
if (item->text() != "Timeline") continue;
CHECK(item->entry().kind == MenuEntry::Kind::Toggle);
CHECK_EQUAL(timeline.isVisible(), item->entry().isChecked());
item->entry().action();
driver.settle();
return;
}
CHECK(false);
};
toggle();
CHECK(timeline.isVisible());
std::vector<std::optional<double>> chosen;
timeline.setOnMomentChosen([&](std::optional<double> moment) { chosen.push_back(moment); });
timeline.setSpan(1000.0, 1000.0 + 10 * kDay);
timeline.showMoment(1000.0 + kDay);
toggle();
CHECK(!timeline.isVisible());
REQUIRE CHECK_EQUAL(1u, chosen.size());
CHECK(!chosen.front().has_value());
}
TEST(ReadOnlyBoardIgnoresEditsAndShortcuts) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
scene.view.taskWidget().showPage(TaskWidgetPage::Task);
driver.settle();
scene.view.openTask(scene.widgets);
driver.settle();
scene.view.setReadOnly(true);
const size_t tasksBefore = scene.board.graph().nodes().size();
CHECK(!scene.view.page().isEffectivelyEnabled());
CHECK(!scene.view.description().editor.sourceArea().isEffectivelyEnabled());
scene.view.createTask();
driver.key(InputID::N, { .control = true });
driver.click(scene.view.graph());
driver.key(InputID::DELETE_KEY);
driver.settle();
CHECK(!scene.view.graphDeleteConfirm().isOpen());
CHECK(!scene.view.header().deleteConfirm.isOpen());
CHECK_EQUAL(tasksBefore, scene.board.graph().nodes().size());
CHECK_EQUAL(0, scene.edits);
scene.view.setReadOnly(false);
CHECK(scene.view.page().isEffectivelyEnabled());
scene.view.createTask();
CHECK_EQUAL(tasksBefore + 1, scene.board.graph().nodes().size());
}
struct RecordingSecretsStore : SecretsStore {
void listSecrets(std::function<void(std::vector<SecretEntry>)> onResult) override {
listCalls++;
onResult(entries);
}
void setSecret(
const std::string& key,
const std::string& value,
const std::string& description,
std::function<void(bool, std::string)> onDone
) override {
stored.push_back({ key, value, description });
bool replaced = false;
for (SecretEntry& entry : entries)
if (entry.key == key) {
entry.description = description;
replaced = true;
}
if (!replaced) entries.push_back({ key, description });
onDone(true, {});
}
void deleteSecret(const std::string& key, std::function<void(bool, std::string)> onDone) override {
deleted.push_back(key);
entries.erase(
std::remove_if(entries.begin(), entries.end(), [&](const SecretEntry& entry) { return entry.key == key; }),
entries.end()
);
onDone(true, {});
}
struct Stored {
std::string key;
std::string value;
std::string description;
};
int listCalls = 0;
std::vector<SecretEntry> entries;
std::vector<Stored> stored;
std::vector<std::string> deleted;
};
struct SkillsScene {
MemoryFileSystem files;
ApplicationFrame window;
SkillsPanel panel{ "/skills", files };
SkillsScene() {
files.addTextFile("/skills/elvees/SKILL.md", "---\nname: elvees\ndescription: Elvees access\n---\n\n# Elvees\n");
files.addTextFile("/skills/generic/SKILL.md", "---\nname: generic\ndescription: Conventions\n---\n\n# Generic\n");
panel.refresh();
window.setContent(&panel);
}
};
TEST(SkillsTabEditsSkillFileOnDisk) {
SkillsScene scene;
UiDriver driver(scene.window, { 1200, 800 });
REQUIRE CHECK(scene.panel.browser.isVisible());
REQUIRE CHECK(scene.panel.browser.row("elvees") != nullptr);
scene.panel.browser.onFileOpened("/skills/elvees/SKILL.md");
driver.settle();
CHECK(shownOnScreen(scene.panel.document));
CHECK(scene.panel.document.markdown().find("# Elvees") != std::string::npos);
scene.panel.document.setMode(MarkdownEditor::Mode::Editor);
driver.settle();
driver.click(scene.panel.document.sourceArea());
driver.type("!");
CHECK_EQUAL(scene.panel.document.markdown(), *scene.files.readText("/skills/elvees/SKILL.md"));
CHECK(scene.files.readText("/skills/elvees/SKILL.md")->find('!') != std::string::npos);
}
TEST(SkillsTabCreatesNewSkillFolder) {
SkillsScene scene;
UiDriver driver(scene.window, { 1200, 800 });
CHECK(scene.panel.createSkill("brand-new"));
driver.settle();
CHECK(scene.files.exists("/skills/brand-new/SKILL.md"));
CHECK(scene.files.readText("/skills/brand-new/SKILL.md")->find("name: brand-new") != std::string::npos);
CHECK(!scene.panel.createSkill("brand-new"));
CHECK(!scene.panel.createSkill("../escape"));
}
TEST(SkillsLibraryShowsNoHeaderHintOrNewSkillButton) {
SkillsScene scene;
UiDriver driver(scene.window, { 1200, 800 });
driver.settle();
CHECK(shownOnScreen(scene.panel.browser));
CHECK(scene.panel.browser.row("elvees") != nullptr);
CHECK(!driver.paint().hasText("Back"));
CHECK(!driver.paint().hasText("Skills"));
CHECK(!driver.paint().hasText("New skill"));
CHECK(!driver.paint().hasText("Each skill is a folder with a SKILL.md"));
}
TEST(OpeningASkillShowsBackAndReturningToTheLibraryHidesIt) {
SkillsScene scene;
UiDriver driver(scene.window, { 1200, 800 });
scene.panel.browser.onFileOpened("/skills/elvees/SKILL.md");
driver.settle();
REQUIRE CHECK(shownOnScreen(scene.panel.document));
CHECK(driver.paint().hasText("Back"));
Button* back = findButton(scene.panel, "Back");
REQUIRE CHECK(back != nullptr);
driver.click(*back);
driver.settle();
CHECK(shownOnScreen(scene.panel.browser));
CHECK(!driver.paint().hasText("Back"));
}
TEST(SecretsTabListsAddsAndDeletesKeysWithoutShowingValues) {
RecordingSecretsStore store;
store.entries = { { "elvees.gitlab_token", "Elvees GitLab PAT" } };
ApplicationFrame window;
SecretsPanel panel;
window.setContent(&panel);
UiDriver driver(window, { 1000, 800 });
panel.setStore(&store);
driver.settle();
CHECK(store.listCalls >= 1);
CHECK_EQUAL("Add", panel.save.text());
panel.key.setText("selfhost.ssh_user");
panel.value.setText("root");
panel.description.setText("server login");
driver.click(panel.save);
driver.settle();
REQUIRE CHECK_EQUAL(1u, store.stored.size());
CHECK_EQUAL("selfhost.ssh_user", store.stored[0].key);
CHECK_EQUAL("root", store.stored[0].value);
CHECK_EQUAL("", panel.value.text());
Button* deleteButton = findButton(panel, "Delete");
REQUIRE CHECK(deleteButton != nullptr);
driver.click(*deleteButton);
driver.settle();
REQUIRE CHECK_EQUAL(1u, store.deleted.size());
CHECK_EQUAL("elvees.gitlab_token", store.deleted[0]);
}
TEST(SecretsTabEditPopulatesFormAndUpdatesEntry) {
RecordingSecretsStore store;
store.entries = { { "elvees.gitlab_token", "GitLab PAT" } };
ApplicationFrame window;
SecretsPanel panel;
window.setContent(&panel);
UiDriver driver(window, { 1000, 800 });
panel.setStore(&store);
driver.settle();
Button* editButton = findButton(panel, "Edit");
REQUIRE CHECK(editButton != nullptr);
driver.click(*editButton);
driver.settle();
CHECK_EQUAL("elvees.gitlab_token", panel.key.text());
CHECK_EQUAL("GitLab PAT", panel.description.text());
CHECK_EQUAL("", panel.value.text());
CHECK_EQUAL("Update", panel.save.text());
panel.value.setText("glpat-newvalue");
driver.click(panel.save);
driver.settle();
REQUIRE CHECK_EQUAL(1u, store.stored.size());
CHECK_EQUAL("elvees.gitlab_token", store.stored[0].key);
CHECK_EQUAL("glpat-newvalue", store.stored[0].value);
CHECK_EQUAL("", panel.key.text());
CHECK_EQUAL("Add", panel.save.text());
}
}
SUITE(TaskContextMenu) {
MenuItem* menuItem(Menu & menu, const std::string& text) {
for (const auto& item : menu.items())
if (item->text() == text) return item.get();
return nullptr;
}
std::vector<std::string> menuTexts(Menu & menu) {
std::vector<std::string> texts;
for (const auto& item : menu.items())
texts.push_back(item->text());
return texts;
}
void chooseItem(UiDriver & driver, Menu & menu, const std::string& text) {
MenuItem* item = menuItem(menu, text);
REQUIRE CHECK(item != nullptr);
REQUIRE CHECK(item->isEffectivelyEnabled());
driver.click(*item);
driver.settle();
}
void chooseNested(UiDriver & driver, Menu & menu, const std::string& submenu, Menu & nested, const std::string& text) {
MenuItem* opener = menuItem(menu, submenu);
REQUIRE CHECK(opener != nullptr);
driver.moveTo(*opener);
driver.settle();
REQUIRE CHECK(nested.isOpen());
chooseItem(driver, nested, text);
}
Vec2F graphCardPoint(TreeScene & scene, NodeId id) {
TreeView& graph = scene.view.graph();
const RectF card = *graph.cardRect(id);
return graph.windowPosition() + card.pos + card.size / 2.0f;
}
void showTree(UiDriver & driver, TreeScene & scene) {
TreeView& graph = scene.view.graph();
graph.openTo(std::nullopt, graph.structure().height());
graph.select(scene.widgets);
driver.settle();
}
void rightClick(UiDriver & driver, Vec2F windowPoint) {
driver.moveTo(windowPoint);
driver.press(PointerButton::Secondary);
driver.release(PointerButton::Secondary);
driver.settle();
}
void rightClickTreeCard(UiDriver & driver, TreeScene & scene, NodeId id) { rightClick(driver, graphCardPoint(scene, id)); }
Vec2F treeEmptyPoint(TreeScene & scene) {
TreeView& graph = scene.view.graph();
return graph.windowPosition() + Vec2F(8.0f, graph.size().y - 8.0f);
}
void showKanbanWithBacklog(UiDriver & driver, TreeScene & scene) {
scene.view.kanban().setColumnVisible(scene.view.kanbanColumn(TaskStatus::Backlog), true);
driver.click(scene.view.views().tabButton(1));
driver.settle();
}
Vec2F kanbanColumnEmptyPoint(TreeScene & scene, TaskStatus status) {
KanbanColumnView& column = scene.view.kanban().columnView(scene.view.kanbanColumn(status));
const RectF rect = column.windowRect();
return rect.pos + Vec2F(rect.size.x / 2.0f, rect.size.y - 12.0f);
}
TEST(RightClickOnATreeCardSelectsItAndOffersTaskActions) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
rightClickTreeCard(driver, scene, scene.release);
Menu& menu = scene.view.contextMenu().menu();
REQUIRE CHECK(menu.isOpen());
CHECK(scene.view.graph().selection() == std::optional<TreeItemId>(scene.release));
const std::vector<std::string> expected = {
"Open", "New child task", "Status", "Session", "Copy", "Paste as children", "Delete task..."
};
CHECK(menuTexts(menu) == expected);
CHECK(!menuItem(menu, "Paste as children")->isEffectivelyEnabled());
}
TEST(KanbanCardMenuMatchesTheTreeCardMenu) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
rightClickTreeCard(driver, scene, scene.release);
const std::vector<std::string> treeItems = menuTexts(scene.view.contextMenu().menu());
const std::vector<std::string> treeStatuses = menuTexts(scene.view.contextMenu().statusMenu());
const std::vector<std::string> treeSession = menuTexts(scene.view.contextMenu().sessionMenu());
scene.view.contextMenu().menu().closeMenu();
driver.settle();
showKanbanWithBacklog(driver, scene);
rightClick(driver, driver.centerOf(*scene.view.kanban().card(scene.release)));
REQUIRE CHECK(scene.view.contextMenu().menu().isOpen());
CHECK(scene.view.kanban().selection() == std::optional<KanbanItemId>(scene.release));
CHECK(menuTexts(scene.view.contextMenu().menu()) == treeItems);
CHECK(menuTexts(scene.view.contextMenu().statusMenu()) == treeStatuses);
CHECK(menuTexts(scene.view.contextMenu().sessionMenu()) == treeSession);
}
TEST(StatusSubmenuChangesTheTaskStatusAndChecksTheCurrentOne) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
rightClickTreeCard(driver, scene, scene.release);
TaskContextMenu& context = scene.view.contextMenu();
const int editsBefore = scene.edits;
chooseNested(driver, context.menu(), "Status", context.statusMenu(), std::string(statusLabel(TaskStatus::Review)));
CHECK(scene.board.task(scene.release)->status == TaskStatus::Review);
CHECK(scene.edits > editsBefore);
CHECK(!context.menu().isOpen());
rightClickTreeCard(driver, scene, scene.release);
CHECK(context.statusMenu().items()[size_t(TaskStatus::Review)]->entry().isChecked());
CHECK(!context.statusMenu().items()[size_t(TaskStatus::Backlog)]->entry().isChecked());
}
TEST(SessionSubmenuControlsTheSessionThroughTheStore) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
TaskContextMenu& context = scene.view.contextMenu();
rightClickTreeCard(driver, scene, scene.release);
CHECK(!menuItem(context.sessionMenu(), "Stop session")->isEffectivelyEnabled());
chooseNested(driver, context.menu(), "Session", context.sessionMenu(), "Start session");
REQUIRE CHECK_EQUAL(1u, scene.store.started.size());
CHECK_EQUAL(scene.release, scene.store.started.front());
scene.board.task(scene.release)->session.state = SessionState::Working;
rightClickTreeCard(driver, scene, scene.release);
CHECK(!menuItem(context.sessionMenu(), "Start session")->isEffectivelyEnabled());
chooseNested(driver, context.menu(), "Session", context.sessionMenu(), "Restart fresh");
REQUIRE CHECK_EQUAL(1u, scene.store.restarts.size());
CHECK(scene.store.restarts.front() == std::make_pair(scene.release, true));
rightClickTreeCard(driver, scene, scene.release);
chooseNested(driver, context.menu(), "Session", context.sessionMenu(), "Stop session");
REQUIRE CHECK_EQUAL(1u, scene.store.stopped.size());
CHECK_EQUAL(scene.release, scene.store.stopped.front());
}
TEST(EndedSessionOffersResume) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
scene.board.task(scene.release)->session.state = SessionState::Ended;
rightClickTreeCard(driver, scene, scene.release);
CHECK(menuItem(scene.view.contextMenu().sessionMenu(), "Resume session") != nullptr);
}
TEST(CopyThenPasteOnEmptySpaceDuplicatesTheSubtreeAtTopLevel) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
scene.board.task(scene.textEdit)->session.state = SessionState::Working;
const size_t tasksBefore = scene.board.graph().nodes().size();
rightClickTreeCard(driver, scene, scene.textEdit);
chooseItem(driver, scene.view.contextMenu().menu(), "Copy");
rightClick(driver, treeEmptyPoint(scene));
Menu& menu = scene.view.contextMenu().menu();
REQUIRE CHECK(menu.isOpen());
CHECK(menuItem(menu, "Copy") == nullptr);
CHECK(menuItem(menu, "Delete task...") == nullptr);
chooseItem(driver, menu, "Paste 2 tasks");
REQUIRE CHECK_EQUAL(tasksBefore + 2, scene.board.graph().nodes().size());
const std::optional<NodeId> pasted = scene.view.currentTask();
REQUIRE CHECK(pasted && *pasted != scene.textEdit);
const Task& copy = *scene.board.task(*pasted);
CHECK_EQUAL("Text edit glitches", copy.title);
CHECK(copy.session.state == SessionState::None);
CHECK(!scene.board.parentOf(*pasted));
const std::vector<NodeId> children = scene.board.childrenOf(*pasted);
REQUIRE CHECK_EQUAL(1u, children.size());
CHECK_EQUAL("Emoji fallback", scene.board.task(children.front())->title);
}
TEST(PasteOnATaskAddsTheCopiesAsItsChildren) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
rightClickTreeCard(driver, scene, scene.release);
chooseItem(driver, scene.view.contextMenu().menu(), "Copy");
rightClickTreeCard(driver, scene, scene.widgets);
chooseItem(driver, scene.view.contextMenu().menu(), "Paste 1 task as children");
const std::vector<NodeId> children = scene.board.childrenOf(scene.widgets);
REQUIRE CHECK_EQUAL(2u, children.size());
CHECK_EQUAL("Release notes", scene.board.task(children.back())->title);
CHECK(scene.board.task(children.back())->workspace.empty());
}
TEST(KeyboardCopyPasteWorksOnTheFocusedTree) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
driver.click(graphCardPoint(scene, scene.release));
driver.settle();
const size_t tasksBefore = scene.board.graph().nodes().size();
driver.key(InputID::C, { .control = true });
driver.key(InputID::V, { .control = true });
driver.settle();
CHECK_EQUAL(tasksBefore + 1, scene.board.graph().nodes().size());
CHECK(scene.board.parentOf(*scene.view.currentTask()) == std::optional<NodeId>(scene.release));
}
TEST(KanbanEmptySpaceMenuCreatesAndPastesIntoThatColumn) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showKanbanWithBacklog(driver, scene);
rightClick(driver, driver.centerOf(*scene.view.kanban().card(scene.release)));
chooseItem(driver, scene.view.contextMenu().menu(), "Copy");
rightClick(driver, kanbanColumnEmptyPoint(scene, TaskStatus::Queued));
Menu& menu = scene.view.contextMenu().menu();
REQUIRE CHECK(menu.isOpen());
const std::string queued(statusLabel(TaskStatus::Queued));
REQUIRE CHECK(menuItem(menu, "New task in " + queued) != nullptr);
chooseItem(driver, menu, "Paste 1 task into " + queued);
const std::optional<NodeId> pasted = scene.view.currentTask();
REQUIRE CHECK(pasted && *pasted != scene.release);
CHECK(scene.board.task(*pasted)->status == TaskStatus::Queued);
rightClick(driver, kanbanColumnEmptyPoint(scene, TaskStatus::Queued));
chooseItem(driver, scene.view.contextMenu().menu(), "New task in " + queued);
const std::optional<NodeId> created = scene.view.currentTask();
REQUIRE CHECK(created && *created != *pasted);
CHECK(scene.board.task(*created)->status == TaskStatus::Queued);
CHECK(!scene.board.parentOf(*created));
}
TEST(MultiSelectionMenuActsOnEveryTask) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
scene.view.graph().setSelection({ scene.release, scene.emoji }, scene.emoji);
driver.settle();
REQUIRE CHECK_EQUAL(2u, scene.view.graph().selectedItems().size());
rightClickTreeCard(driver, scene, scene.emoji);
TaskContextMenu& context = scene.view.contextMenu();
REQUIRE CHECK_EQUAL(2u, context.target().tasks.size());
CHECK(menuItem(context.menu(), "Open") == nullptr);
CHECK(menuItem(context.menu(), "Session") == nullptr);
chooseNested(driver, context.menu(), "Status of 2 tasks", context.statusMenu(), std::string(statusLabel(TaskStatus::Done)));
CHECK(scene.board.task(scene.release)->status == TaskStatus::Done);
CHECK(scene.board.task(scene.emoji)->status == TaskStatus::Done);
CHECK(scene.board.task(scene.widgets)->status != TaskStatus::Done);
rightClickTreeCard(driver, scene, scene.emoji);
chooseItem(driver, context.menu(), "Delete 2 tasks...");
REQUIRE CHECK(scene.view.graphDeleteConfirm().isOpen());
}
TEST(ReadOnlyBoardDisablesEditingActions) {
TreeScene scene;
UiDriver driver(scene.window, { 1400, 900 });
showTree(driver, scene);
scene.view.setReadOnly(true);
rightClickTreeCard(driver, scene, scene.release);
Menu& menu = scene.view.contextMenu().menu();
CHECK(menuItem(menu, "Open")->isEffectivelyEnabled());
CHECK(menuItem(menu, "Copy")->isEffectivelyEnabled());
CHECK(!menuItem(menu, "New child task")->isEffectivelyEnabled());
CHECK(!menuItem(menu, "Status")->isEffectivelyEnabled());
CHECK(!menuItem(menu, "Delete task...")->isEffectivelyEnabled());
CHECK(!menuItem(scene.view.contextMenu().sessionMenu(), "Start session")->isEffectivelyEnabled());
}
}