Files
feuerwerk/tests/editor/hierarchy-drop.test.ts
T
adminandClaude Opus 5.5 2eeabf7c23
game-maker / gate (push) Successful in 3m26s
game-maker / suite (push) Failing after 9m12s
Drag a whole selection and join existing groups in the hierarchy (#241)
Three follow-ups to the hierarchy groups. Dragging a row that belonged
to a multi-selection moved only that one row. The group dialog could
only create a new group. The group row drew its folder in front of the
label, unlike every other row in the hierarchy.

- Dragging a selected row moves every selected object. This holds for
  reordering, dropping into a group or between its members, dragging
  out of a group and dropping onto an object as new parent.
- The moved objects land side by side in hierarchy order, whichever
  row was grabbed. The whole move is one undo step.
- A selected child of a selected object travels with its parent, not
  on its own. Dropping the selection onto its own rows does nothing.
- Dragging a row that is not selected still moves only that row.
- hierarchyDrop.moveObjects replaces the single-object rule. One object
  is the case with one element, so the old drop tests hold unchanged.
- The group dialog has a "Group" dropdown: New group or an existing
  group. Picking a group greys out the name and turns the button into
  "Add". A group that holds all chosen objects already is not offered.
- Rename keeps using the dialog without the dropdown.
- ListRow draws an end icon at the very right and the hint left of it.
  Only the hierarchy uses iconAtEnd, so other lists do not change.
- New test hook __gm.hierarchyHint() gives the centre of a row's hint.

Not clicked in the browser: dragging a selection in the container tab
and onto an object as parent. Only the unit tests cover these.

Tests:
- ./check.sh: 178 files, 2564 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 27 checks
- 7 new unit tests in hierarchy-drop.test.ts, 4 new e2e checks in
  52i and 52j with real clicks and a real drag
- 4 counter-probes bite: drop rule ignores the selection (5 unit tests
  fail), icon column not kept free, dialog ignores the picked group,
  panel ignores the selection (only o2 lands in the group)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:19:12 +02:00

177 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { oneStep } from '../../src/core/commands/CompoundCommand';
import { GroupObjectsCommand } from '../../src/core/commands/groupCommands';
import {
findObject,
groupMemberIds,
groupRowId,
hierarchyRows,
type HierarchyRow,
} from '../../src/core/model/hierarchy';
import { hierarchyDrop } from '../../src/editor/panels/hierarchyDrop';
import type { DropSpot } from '../../src/ui/widgets/ListView';
import { makeSpriteScene, place } from '../helpers';
/** Top-level a, b, c, d, e; b and d form the group "Crates", so the list reads a [b d] c e. */
function grouped() {
const f = makeSpriteScene();
const [a, b, c, d, e] = [0, 1, 2, 3, 4].map((i) => place(f, f.spriteId, { x: i * 10, y: 0 }));
const k = place(f, f.spriteId, { x: 0, y: 10 }, a);
const group = new GroupObjectsCommand(f.sceneId, [b!, d!], 'Crates');
f.stack.execute(group);
const row = groupRowId(group.groupId);
/**
* Drops like the panel does, with the given rows unfolded and `selected` as the selection;
* returns how many commands it made.
*/
const drop = (dragged: string, spot: DropSpot, open: string[] = [row], selected: string[] = []): number => {
const { commands } = hierarchyDrop(f.sceneOf(), dragged, spot, (id) => open.includes(id), selected);
if (commands.length > 0) f.stack.execute(oneStep('Move object', commands));
return commands.length;
};
const label = (r: HierarchyRow) => (r.kind === 'group' ? `[${r.group.name}]` : name(r.object.id));
const names: Record<string, string> = { [a!]: 'a', [b!]: 'b', [c!]: 'c', [d!]: 'd', [e!]: 'e', [k]: 'k' };
const name = (id: string) => names[id] ?? '?';
/** The open list as text: a group's members follow it one step in. */
const listed = () =>
hierarchyRows(f.sceneOf(), new Set([row, a!]))
.map((r) => `${'.'.repeat(r.depth)}${label(r)}`)
.join(' ');
const members = () => groupMemberIds(f.sceneOf(), group.groupId).map(name);
return { ...f, a: a!, b: b!, c: c!, d: d!, e: e!, k, row, drop, listed, members };
}
describe('dropping rows in the hierarchy with groups (#241)', () => {
it('starts from a group listed as one block', () => {
const f = grouped();
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
it('puts an object dropped onto the group row behind its last member', () => {
const f = grouped();
f.drop(f.e, { at: 'onto', targetId: f.row });
expect(f.members()).toEqual(['b', 'd', 'e']);
expect(f.listed()).toBe('a .k [Crates] .b .d .e c');
f.stack.undo();
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
it('joins the group between two members, and at the top just below an open group row', () => {
const f = grouped();
f.drop(f.e, { at: 'after', targetId: f.b });
expect(f.listed()).toBe('a .k [Crates] .b .e .d c');
f.drop(f.c, { at: 'after', targetId: f.row });
expect(f.listed()).toBe('a .k [Crates] .c .b .e .d');
});
it('goes behind a folded group, and in front of any group row, without joining', () => {
const f = grouped();
f.drop(f.a, { at: 'after', targetId: f.row }, []);
expect(f.listed()).toBe('[Crates] .b .d a .k c e');
f.drop(f.e, { at: 'before', targetId: f.row });
expect(f.listed()).toBe('e [Crates] .b .d a .k c');
expect(f.members()).toEqual(['b', 'd']);
});
it('takes a member out when it is dropped beside a row outside the group', () => {
const f = grouped();
f.drop(f.d, { at: 'after', targetId: f.e });
expect(f.members()).toEqual(['b']);
expect(f.listed()).toBe('a .k [Crates] .b c e d');
});
it('brings a child up to the top level when it is dropped into a group', () => {
const f = grouped();
f.drop(f.k, { at: 'before', targetId: f.d });
expect(findObject(f.sceneOf(), f.k)!.parentId).toBe('');
expect(f.listed()).toBe('a [Crates] .b .k .d c e');
});
it('takes a member out of the group when it is dropped onto another object', () => {
const f = grouped();
f.drop(f.b, { at: 'onto', targetId: f.c });
expect(findObject(f.sceneOf(), f.b)!.groupId).toBeUndefined();
expect(f.members()).toEqual(['d']);
});
it('moves a group row with all its members as one block', () => {
const f = grouped();
f.drop(f.row, { at: 'after', targetId: f.e });
expect(f.listed()).toBe('a .k c e [Crates] .b .d');
f.drop(f.row, { at: 'before', targetId: f.a });
expect(f.listed()).toBe('[Crates] .b .d a .k c e');
// One undo takes the whole block back.
f.stack.undo();
expect(f.listed()).toBe('a .k c e [Crates] .b .d');
});
it('makes nothing of a drop that leaves everything where it is', () => {
const f = grouped();
expect(f.drop(f.d, { at: 'after', targetId: f.b })).toBe(0);
expect(f.drop(f.b, { at: 'onto', targetId: f.row })).toBe(0);
expect(f.drop(f.row, { at: 'before', targetId: f.c })).toBe(0);
expect(f.drop(f.row, { at: 'onto', targetId: f.c })).toBe(0);
expect(f.drop(f.row, { at: 'before', targetId: f.k })).toBe(0);
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
});
describe('dragging several selected rows at once', () => {
it('puts every selected object into the group it is dropped onto, as one undo step', () => {
const f = grouped();
f.drop(f.e, { at: 'onto', targetId: f.row }, [f.row], [f.c, f.e]);
expect(f.members()).toEqual(['b', 'd', 'c', 'e']);
expect(f.listed()).toBe('a .k [Crates] .b .d .c .e');
f.stack.undo();
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
it('moves the selection side by side into a gap, in the order it was listed', () => {
const f = grouped();
// Dragged by its last row, still c comes before e.
f.drop(f.e, { at: 'before', targetId: f.a }, [f.row], [f.e, f.c]);
expect(f.listed()).toBe('c e a .k [Crates] .b .d');
});
it('takes members out of the group and joins others to it in the same drop', () => {
const f = grouped();
f.drop(f.b, { at: 'after', targetId: f.e }, [f.row], [f.b, f.a]);
expect(f.members()).toEqual(['d']);
// The group now stands where d, its first member left, is stored – behind c.
expect(f.listed()).toBe('c [Crates] .d e a .k b');
f.drop(f.a, { at: 'after', targetId: f.d }, [f.row], [f.a, f.c]);
expect(f.members()).toEqual(['d', 'c', 'a']);
});
it('parents every selected object to the row they are dropped onto', () => {
const f = grouped();
f.drop(f.c, { at: 'onto', targetId: f.a }, [f.row], [f.c, f.e]);
expect(findObject(f.sceneOf(), f.c)!.parentId).toBe(f.a);
expect(findObject(f.sceneOf(), f.e)!.parentId).toBe(f.a);
});
it('carries a selected child with its selected parent instead of on its own', () => {
const f = grouped();
f.drop(f.a, { at: 'after', targetId: f.e }, [f.row], [f.a, f.k]);
expect(findObject(f.sceneOf(), f.k)!.parentId).toBe(f.a);
expect(f.listed()).toBe('[Crates] .b .d c e a .k');
});
it('moves only the dragged row when it is not part of the selection', () => {
const f = grouped();
f.drop(f.e, { at: 'before', targetId: f.a }, [f.row], [f.c]);
expect(f.listed()).toBe('e a .k [Crates] .b .d c');
});
it('makes nothing of a drop onto or between the selected rows themselves', () => {
const f = grouped();
expect(f.drop(f.c, { at: 'onto', targetId: f.e }, [f.row], [f.c, f.e])).toBe(0);
expect(f.drop(f.a, { at: 'before', targetId: f.k }, [f.row], [f.a, f.c])).toBe(0);
expect(f.drop(f.c, { at: 'after', targetId: f.d }, [f.row], [f.c, f.e])).toBeGreaterThan(0);
f.stack.undo();
// Already side by side at the end of the top level: nothing to do.
expect(f.drop(f.c, { at: 'end' }, [f.row], [f.c, f.e])).toBe(0);
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
});