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 = { [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'); }); });