Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/desktop/e2e/composer-plus-menu-stability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,18 @@ test('latest Plan intent still reaches the Host after a catalog refresh fails',
await expect(planRow).toHaveAttribute('aria-checked', 'false');
});

test('deleting the session while a toggle is pending settles clean', async ({
test('removing the session while a toggle is pending settles clean', async ({
invocableSkillsWindow: page,
}) => {
// pending → cleanup → settle: the Session's renderer lifecycle ends while a
// mode commit (and a queued follow-up intent) is still in flight. Cleanup
// must drop the queued ask with the rest of the Session state, so the
// commit's tail has nothing to replay against the removed Session.
//
// Archiving is what ends that lifecycle from the rail now that deleting has
// moved to Settings. It is the same ending as far as this case is concerned:
// `archiveSession` clears the active id, the active messages and the whole
// family's renderer state, exactly as removal did.
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('alpha-marker');
await composer.press('Enter');
Expand All @@ -389,9 +394,7 @@ test('deleting the session while a toggle is pending settles clean', async ({
const row = sidebar.locator('[data-maka-contract="session-row"]').first();
await row.hover();
await row.getByRole('button', { name: '任务操作' }).click();
await page.getByRole('menuitem', { name: '删除', exact: true }).click();
const confirm = page.getByRole('alertdialog');
await confirm.getByRole('button', { name: '删除', exact: true }).click();
await page.getByRole('menuitem', { name: '归档', exact: true }).click();

await releaseBridgeLatch(page, 'sessions.list');
await expect(sidebar.locator('[data-maka-contract="session-row"]')).toHaveCount(0);
Expand Down
33 changes: 24 additions & 9 deletions apps/desktop/e2e/parent-session-deletion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import {
test,
} from './fixtures';

/**
* The route is two steps on purpose: the rail archives, Settings deletes. The
* rail has no delete at all — a mis-click there is one hover away from an
* irreversible loss — so a task must have been archived once before anything
* can remove it. This walks the whole route rather than calling the command,
* because the route is the thing that changed.
*/
test('deleting a parent task archives its linked subagent task', async ({
parentRemovalWindow: page,
}) => {
Expand All @@ -38,7 +45,23 @@ test('deleting a parent task archives its linked subagent task', async ({

await parentRow.hover();
await parentRow.getByRole('button', { name: '任务操作' }).click();
await page.getByRole('menuitem', { name: '删除', exact: true }).click();
// The rail's menu ends at 归档. Deleting is not one of the things a row can
// be asked to do here.
await expect(page.getByRole('menuitem', { name: '删除', exact: true })).toHaveCount(0);
await page.getByRole('menuitem', { name: '归档', exact: true }).click();
await expect(parentRow).toHaveCount(0);

await page.getByRole('button', { name: '设置', exact: true }).click();
await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible();
await page.getByRole('button', { name: '已归档任务', exact: true }).click();
const archivedTasks = page.getByRole('main', { name: '设置内容' });

await archivedTasks
.getByRole('button', { name: `「${PARENT_REMOVAL_PARENT_NAME}」的更多操作` })
.click();
// 彻底删除, not 删除: Settings names the irreversible verb in full, which is
// the point of routing every deletion through a surface reached by archiving.
await page.getByRole('menuitem', { name: '彻底删除', exact: true }).click();
const confirm = page.getByRole('alertdialog', {
name: `删除 "${PARENT_REMOVAL_PARENT_NAME}"`,
});
Expand All @@ -49,14 +72,6 @@ test('deleting a parent task archives its linked subagent task', async ({
await expect(confirm.getByText(/子任务.*归档/)).toBeVisible();
await confirm.getByRole('button', { name: '删除', exact: true }).click();

await expect(parentRow).toHaveCount(0);
await expect(taskList.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toHaveCount(0);

await page.getByRole('button', { name: '设置', exact: true }).click();
await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible();
await page.getByRole('button', { name: '已归档任务', exact: true }).click();

const archivedTasks = page.getByRole('main', { name: '设置内容' });
await expect(archivedTasks.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toBeVisible();
await expect(archivedTasks.getByText(/原父任务已删除/)).toBeVisible();
await expect(archivedTasks.getByText(PARENT_REMOVAL_PARENT_NAME, { exact: true })).toHaveCount(0);
Expand Down
148 changes: 91 additions & 57 deletions apps/desktop/src/main/__tests__/session-navigation-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,101 +19,135 @@

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { SessionRowPick } from '@maka/ui';
import {
EMPTY_SESSION_SELECTION,
enterSessionSelection,
exitSessionSelection,
pickSessionRow,
pruneSessionSelection,
sessionSelectionMasterState,
setAllSessionsSelected,
type SessionSelection,
} from '../../renderer/features/session-navigation/testing.js';

const GROUP = ['a', 'b', 'c', 'd', 'e'];
const ORDER = ['a', 'b', 'c', 'd', 'e'];

function ids(selection: SessionSelection): string[] {
return [...selection.selectedIds].sort();
}

/** What a row's checkbox does, as the hook applies it. */
function mark(selection: SessionSelection, sessionId: string): SessionSelection {
return {
active: true,
selectedIds: new Set([...selection.selectedIds, sessionId]),
};
/** One click on a row, as the rail's handler passes it down. */
function click(
selection: SessionSelection,
sessionId: string,
pick: SessionRowPick,
openSessionId?: string,
): SessionSelection {
return pickSessionRow(selection, {
sessionId,
pick,
orderedSessionIds: ORDER,
openSessionId,
});
}

describe('selection mode', () => {
test('entering marks nothing on its own', () => {
const entered = enterSessionSelection(EMPTY_SESSION_SELECTION);
assert.equal(entered.active, true);
assert.deepEqual(ids(entered), []);
describe('a plain click', () => {
test('makes the set exactly this row', () => {
const from = click(EMPTY_SESSION_SELECTION, 'a', 'replace');
assert.deepEqual(ids(click(from, 'd', 'replace')), ['d']);
});

test('leaves the anchor on the row that was clicked', () => {
// Which is what lets the very next Shift-click reach from here, without a
// separate gesture to say where a range starts.
const anchored = click(EMPTY_SESSION_SELECTION, 'b', 'replace');
assert.deepEqual(ids(click(anchored, 'd', 'range')), ['b', 'c', 'd']);
});
});

test('leaving drops the mode and the marks together', () => {
assert.equal(exitSessionSelection().active, false);
assert.deepEqual(ids(exitSessionSelection()), []);
describe('⌘-click', () => {
test('adds a row without disturbing the rest', () => {
const one = click(EMPTY_SESSION_SELECTION, 'a', 'replace');
assert.deepEqual(ids(click(one, 'd', 'toggle')), ['a', 'd']);
});

test('unticking every row is select-none, not leave', () => {
// A mode that ended itself on the last untick would take the checkboxes
// away mid-gesture, and one mis-click would cost the user the way back.
const all = setAllSessionsSelected(EMPTY_SESSION_SELECTION, GROUP, true);
const none = setAllSessionsSelected(all, GROUP, false);
assert.deepEqual(ids(none), []);
assert.equal(none.active, true);
test('removes a row it finds already picked', () => {
const two = click(click(EMPTY_SESSION_SELECTION, 'a', 'replace'), 'd', 'toggle');
assert.deepEqual(ids(click(two, 'a', 'toggle')), ['d']);
});

test('an emptied selection keeps the mode it was in', () => {
// It used to settle on the shared EMPTY value, which also carries
// `active: false` — so a catalog change that pruned the last row would have
// taken the checkboxes away while the user was still selecting.
const pruned = pruneSessionSelection(mark(EMPTY_SESSION_SELECTION, 'a'), []);
assert.deepEqual(ids(pruned), []);
assert.equal(pruned.active, true);
test('moves the anchor, including when it unpicked the row', () => {
// The anchor is "where the last non-Shift click landed", not "the last row
// added": a person who unticks a row and then Shift-clicks means the run
// between those two clicks, whatever the first one did to the set.
const removed = click(click(EMPTY_SESSION_SELECTION, 'b', 'replace'), 'b', 'toggle');
assert.deepEqual(ids(removed), []);
assert.deepEqual(ids(click(removed, 'd', 'range')), ['b', 'c', 'd']);
});
});

describe('the master box', () => {
test('marks exactly the rows the rail is listing', () => {
// Not every task in the catalog: the box sits above these rows, and a
// selection that reached past them would name a number nobody agreed to.
assert.deepEqual(ids(setAllSessionsSelected(EMPTY_SESSION_SELECTION, ['a', 'b'], true)), [
'a',
'b',
]);
describe('Shift-click', () => {
test('picks the run between the anchor and the row, in either direction', () => {
const anchored = click(EMPTY_SESSION_SELECTION, 'd', 'replace');
assert.deepEqual(ids(click(anchored, 'b', 'range')), ['b', 'c', 'd']);
});

test('keeps the anchor so the run can be re-dragged from the same origin', () => {
// Shortening a range is the same gesture as lengthening it. An anchor that
// moved to the last row touched would make the second Shift-click reach
// from the end of the first one, and the run would only ever grow.
const anchored = click(EMPTY_SESSION_SELECTION, 'b', 'replace');
const long = click(anchored, 'e', 'range');
assert.deepEqual(ids(click(long, 'c', 'range')), ['b', 'c']);
});

test('reads unchecked, indeterminate, then checked', () => {
assert.equal(sessionSelectionMasterState(EMPTY_SESSION_SELECTION, GROUP), false);
assert.equal(sessionSelectionMasterState(mark(EMPTY_SESSION_SELECTION, 'b'), GROUP), 'indeterminate');
assert.equal(
sessionSelectionMasterState(setAllSessionsSelected(EMPTY_SESSION_SELECTION, GROUP, true), GROUP),
true,
);
test('starts from the open task when no click has set an anchor', () => {
// The rail is a navigation surface first: a person who has been reading a
// task and Shift-clicks another means the two of them and everything
// between, without a preparatory click to say so.
assert.deepEqual(ids(click(EMPTY_SESSION_SELECTION, 'd', 'range', 'b')), ['b', 'c', 'd']);
});

test('an empty list is unchecked, never checked', () => {
// `every` over an empty array is vacuously true, which would tick the box
// above no rows at all.
assert.equal(sessionSelectionMasterState(EMPTY_SESSION_SELECTION, []), false);
test('with nothing to reach from is just this row', () => {
assert.deepEqual(ids(click(EMPTY_SESSION_SELECTION, 'd', 'range')), ['d']);
});

test('a mark outside the listed rows does not make it checked', () => {
assert.equal(sessionSelectionMasterState(mark(EMPTY_SESSION_SELECTION, 'zzz'), GROUP), 'indeterminate');
test('reaching for a row the list is not showing picks only the row clicked', () => {
const stale = pickSessionRow(EMPTY_SESSION_SELECTION, {
sessionId: 'd',
pick: 'range',
orderedSessionIds: ORDER,
openSessionId: 'gone',
});
assert.deepEqual(ids(stale), ['d']);
});
});

describe('clearing', () => {
test('drops the picks and the anchor together', () => {
const cleared = EMPTY_SESSION_SELECTION;
assert.deepEqual(ids(cleared), []);
// With no anchor left, the next range starts from whatever is open —
// which is where the user's attention already is.
assert.deepEqual(ids(click(cleared, 'c', 'range', 'a')), ['a', 'b', 'c']);
});
});

describe('pruneSessionSelection', () => {
test('drops ids the catalog no longer lists', () => {
const selection = setAllSessionsSelected(EMPTY_SESSION_SELECTION, ['a', 'b'], true);
assert.deepEqual(ids(pruneSessionSelection(selection, ['a'])), ['a']);
const two = click(click(EMPTY_SESSION_SELECTION, 'a', 'replace'), 'b', 'toggle');
assert.deepEqual(ids(pruneSessionSelection(two, ['a'])), ['a']);
});

test('drops an anchor that went with them', () => {
// A range from a row that is gone would reach across the rows that took
// its place, which is not the run anybody drew.
const anchored = click(EMPTY_SESSION_SELECTION, 'b', 'replace');
const pruned = pruneSessionSelection(anchored, ['a', 'c', 'd', 'e']);
assert.deepEqual(ids(click(pruned, 'd', 'range', 'c')), ['c', 'd']);
});

test('returns the same value when nothing was dropped', () => {
// Identity matters here: this runs on every catalog refresh, and a new Set
// each time would re-render every row of the rail.
const selection = mark(EMPTY_SESSION_SELECTION, 'a');
const selection = click(EMPTY_SESSION_SELECTION, 'a', 'replace');
assert.equal(pruneSessionSelection(selection, ['a', 'b']), selection);
});
});
Loading