import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { GroupObjectsCommand, RenameGroupCommand, SetObjectGroupCommand, UngroupCommand, } from '../../src/core/commands/groupCommands'; import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands'; import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import type { ContainerAsset } from '../../src/core/model/Asset'; import { copyObjects } from '../../src/core/model/clipboard'; import { canGroup, findObject, groupMemberIds, groupRowId, hierarchyRows, treeOrder, } from '../../src/core/model/hierarchy'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import { findAsset, findScene } from '../../src/core/model/Project'; import { serializeProject } from '../../src/core/store/serialize'; import { makeSpriteScene, place, roundTrip } from '../helpers'; /** Five top-level boxes a–e in that stored order; `c` has a child `k`. */ function fiveBoxes() { 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: 20, y: 10 }, c); return { ...f, a: a!, b: b!, c: c!, d: d!, e: e!, k }; } const ids = (entries: { object: { id: string } }[]) => entries.map((entry) => entry.object.id); describe('hierarchy groups (#241)', () => { it('lists a group as one block where its first member stands, and draws equal z in that order', () => { const { stack, sceneId, sceneOf, a, b, c, d, e, k } = fiveBoxes(); stack.execute(new GroupObjectsCommand(sceneId, [b, d], 'Crates')); // d moves up behind b; the stored list is not touched. expect(ids(treeOrder(sceneOf()))).toEqual([a, b, d, c, k, e]); expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, d, e, k]); }); it('shows a row over the members, indents them and folds them away', () => { const { stack, sceneId, sceneOf, a, b, c, d, e, k } = fiveBoxes(); const group = new GroupObjectsCommand(sceneId, [c, e], 'Crates'); stack.execute(group); const row = groupRowId(group.groupId); const rows = (expanded: string[]) => hierarchyRows(sceneOf(), new Set(expanded)).map((r) => r.kind === 'group' ? [r.group.name, r.memberIds.length, r.depth] : [r.object.id, r.depth], ); // Folded, the way a freshly opened scene starts. expect(rows([])).toEqual([ [a, 0], [b, 0], ['Crates', 2, 0], [d, 0], ]); // Open: members one step in, the child of a member one more. expect(rows([row, c])).toEqual([ [a, 0], [b, 0], ['Crates', 2, 0], [c, 1], [k, 2], [e, 1], [d, 0], ]); expect(rows([row])).toEqual([ [a, 0], [b, 0], ['Crates', 2, 0], [c, 1], [e, 1], [d, 0], ]); }); it('only groups top-level objects', () => { const { stack, sceneId, sceneOf, a, k } = fiveBoxes(); expect(canGroup(sceneOf(), [a])).toBe(true); expect(canGroup(sceneOf(), [a, k])).toBe(false); expect(() => stack.execute(new GroupObjectsCommand(sceneId, [a, k], 'G'))).toThrow(/top-level/); expect(sceneOf().groups).toBeUndefined(); expect(findObject(sceneOf(), a)!.groupId).toBeUndefined(); }); it('takes an object out of its group when it goes under a parent, and back on undo', () => { const { stack, sceneId, sceneOf, a, b, c } = fiveBoxes(); const group = new GroupObjectsCommand(sceneId, [b, c], 'G'); stack.execute(group); stack.execute(new SetParentCommand(sceneId, b, a)); expect(findObject(sceneOf(), b)!.groupId).toBeUndefined(); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([c]); stack.undo(); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]); }); it('renames, ungroups and moves members in and out, each one undo step', () => { const { stack, sceneId, sceneOf, a, b, c } = fiveBoxes(); const group = new GroupObjectsCommand(sceneId, [a, b], 'Group'); stack.execute(group); stack.execute(new RenameGroupCommand(sceneId, group.groupId, 'Crates')); stack.execute(new SetObjectGroupCommand(sceneId, [c], group.groupId)); stack.execute(new SetObjectGroupCommand(sceneId, [a], '')); expect(sceneOf().groups).toEqual([{ id: group.groupId, name: 'Crates' }]); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]); stack.execute(new UngroupCommand(sceneId, group.groupId)); expect(sceneOf().groups).toBeUndefined(); expect(sceneOf().objects.some((o) => o.groupId)).toBe(false); stack.undo(); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]); stack.undo(); stack.undo(); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([a, b]); stack.undo(); expect(sceneOf().groups).toEqual([{ id: group.groupId, name: 'Group' }]); stack.undo(); expect(sceneOf().groups).toBeUndefined(); expect(sceneOf().objects.some((o) => o.groupId !== undefined)).toBe(false); }); it('keeps groups across saving and drops what no longer holds together', () => { const f = fiveBoxes(); const { project, stack, sceneId, a, b, c, k } = f; const kept = new GroupObjectsCommand(sceneId, [a, c], 'Crates'); const emptied = new GroupObjectsCommand(sceneId, [b], 'Gone'); stack.execute(kept); stack.execute(emptied); stack.execute(new SetObjectGroupCommand(sceneId, [b], '')); // A group id on a child and one naming no group, as a hand-edited file could have them. const raw = serializeProject(project) as { assets: { id: string; objects?: { id: string; groupId?: string }[] }[]; }; const scene = raw.assets.find((asset) => asset.id === sceneId)!; scene.objects!.find((o) => o.id === k)!.groupId = kept.groupId; scene.objects!.find((o) => o.id === b)!.groupId = 'nowhere'; const loaded = findScene(roundTrip(raw as never), sceneId)!; expect(loaded.groups).toEqual([{ id: kept.groupId, name: 'Crates' }]); expect(groupMemberIds(loaded, kept.groupId)).toEqual([a, c]); expect(findObject(loaded, k)!.groupId).toBeUndefined(); expect(findObject(loaded, b)!.groupId).toBeUndefined(); }); it('loads a project from before groups unchanged', () => { const { project, sceneId } = fiveBoxes(); const loaded = roundTrip(project); expect(JSON.stringify(serializeProject(loaded))).not.toMatch(/group/i); expect(findScene(loaded, sceneId)!.groups).toBeUndefined(); expect(treeOrder(findScene(loaded, sceneId)!).map((e) => e.object.id)).toEqual( treeOrder(findScene(project, sceneId)!).map((e) => e.object.id), ); }); it('pastes a copy into the group only at the top level of a holder that has the group', () => { const { project, stack, sceneId, sceneOf, a, b } = fiveBoxes(); const group = new GroupObjectsCommand(sceneId, [a], 'Crates'); stack.execute(group); const clip = copyObjects(project, sceneOf(), [a])!; const beside = new PasteObjectsCommand(sceneId, clip, {}, { x: 5, y: 0 }); stack.execute(beside); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([a, beside.rootIds[0]]); const under = new PasteObjectsCommand(sceneId, clip, { [a]: b }, { x: 5, y: 0 }); stack.execute(under); expect(findObject(sceneOf(), under.rootIds[0]!)!.groupId).toBeUndefined(); const other = new CreateAssetCommand('scene', 'Other'); stack.execute(other); const elsewhere = new PasteObjectsCommand(other.asset.id, clip, {}, { x: 0, y: 0 }); stack.execute(elsewhere); expect(findObject(findScene(project, other.asset.id)!, elsewhere.rootIds[0]!)!.groupId).toBeUndefined(); }); it('puts a container made of one group in its place and leaves the template without groups', () => { const { project, stack, sceneId, sceneOf, a, b, c } = fiveBoxes(); const group = new GroupObjectsCommand(sceneId, [a, b, c], 'Crates'); stack.execute(group); const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([c, create.objectId]); const template = findAsset(project, create.assetId) as ContainerAsset; expect(template.objects.some((o) => o.groupId !== undefined)).toBe(false); }); it('orders the content of a placed container the way its template lists it', () => { const f = makeSpriteScene(); const container = new CreateAssetCommand('container', 'Crates'); f.stack.execute(container); const holderId = container.asset.id; const [x, y, z] = [0, 1, 2].map((i) => { const put = new PlaceAssetCommand(holderId, f.spriteId, { x: i * 10, y: 0 }); f.stack.execute(put); return put.objectId; }); f.stack.execute(new GroupObjectsCommand(holderId, [x!, z!], 'Pair')); const template = findAsset(f.project, holderId) as ContainerAsset; expect(ids(treeOrder(template))).toEqual([x, z, y]); const instance = place(f, holderId, { x: 0, y: 0 }); const content = treeOrder(resolvedHolder(f.project, f.sceneOf())) .filter((entry) => entry.object.parentId === instance) .map((entry) => entry.object.id); expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!))); }); });