Files
feuerwerk/tests/viewport/pick.test.ts
T
adminandClaude Opus 5 8c5846f956 Right-click in the scene view opens the object menu (#204)
The object menu lived only on the hierarchy rows. Now a right-click in
the scene view opens the same menu for the object under the cursor.

- The menu moves out of HierarchyPanel into src/editor/objectMenu.ts.
  Hierarchy and scene view call the same function, so their entries
  cannot drift apart. Only the hierarchy adds "Expand all" and
  "Collapse all".
- Which object the menu is for follows the press rule from #203. A
  selected object under the cursor, even a hidden one, keeps the
  selection and the menu acts on all of it. Otherwise the topmost
  object is selected first.
- The rule is the pure function menuTargetOf in src/viewport/pick.ts,
  next to pressOn.
- Empty space, including an empty tilemap cell, opens no menu.
- The container tab gets the same, because ContainerCanvas extends the
  scene view.

Tests:
- ./check.sh: 142 files, 2094 tests, exit 0 (was 2090)
- e2e 39,40 fresh: ALL PASSED, 80 checks
- e2e 1-16,39,40 fresh: ALL PASSED, 565 checks
- New block 40r (3 checks): menu on a hidden selected crate, topmost
  object without a selection, no menu on an empty cell.
- Counter-check: a right-click that always takes the topmost object
  fails 2 unit tests and the hidden-crate e2e check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:47:31 +02:00

127 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { DrawItem } from '../../src/viewport/items';
import { menuTargetOf, pressOn, stackAt } from '../../src/viewport/pick';
/** Only what the pick order reads: the type, the `z`, and a map's layers with their own `z`. */
function item(
id: string,
type: DrawItem['type'],
z: number,
layers: { id: string; z: number }[] = [],
): DrawItem {
return {
id,
type,
z,
tilemap: type === 'tilemap' ? { layers: layers.map((l) => ({ ...l, tiles: [] })) } : null,
} as unknown as DrawItem;
}
describe('what lies under the pointer (#203)', () => {
const map = item('map', 'tilemap', 0, [
{ id: 'ground', z: -5 },
{ id: 'roofs', z: 5 },
]);
const hero = item('hero', 'sprite', -1);
it('puts a sprite in front of a map whose only tile there is on a layer below it', () => {
expect(
stackAt(
[map, hero],
[
{ id: 'map', layers: ['ground'] },
{ id: 'hero', layers: null },
],
),
).toEqual(['hero', 'map']);
});
it('puts the map in front where a layer above the sprite has a tile', () => {
expect(
stackAt(
[map, hero],
[
{ id: 'map', layers: ['ground', 'roofs'] },
{ id: 'hero', layers: null },
],
),
).toEqual(['map', 'hero']);
});
it('leaves out a map without a tile under the pointer', () => {
expect(
stackAt(
[map, hero],
[
{ id: 'map', layers: [] },
{ id: 'hero', layers: null },
],
),
).toEqual(['hero']);
});
it('orders sprites by z, ties in hierarchy order, with symbols in front of all', () => {
const items = [
item('a', 'sprite', 2),
item('b', 'sprite', 0),
item('c', 'sprite', 2),
item('box', 'collider', -9),
];
const hits = items.map((i) => ({ id: i.id, layers: null }));
expect(stackAt(items, hits)).toEqual(['box', 'c', 'a', 'b']);
});
});
describe('what a press picks (#203)', () => {
const stack = ['top', 'middle', 'bottom'];
it('selects the topmost when nothing selected is under the pointer', () => {
expect(pressOn(stack, ['elsewhere'], 'elsewhere')).toEqual({ select: 'top', onClick: null });
});
it('keeps a selection hidden under others, so a drag moves it', () => {
expect(pressOn(stack, ['bottom'], 'bottom')?.select).toBeNull();
});
it('passes a click on to the next one below, and round to the top again', () => {
expect(pressOn(stack, ['top'], 'top')?.onClick).toBe('middle');
expect(pressOn(stack, ['middle'], 'middle')?.onClick).toBe('bottom');
expect(pressOn(stack, ['bottom'], 'bottom')?.onClick).toBe('top');
});
it('goes on from the primary one of a group', () => {
expect(pressOn(stack, ['top', 'middle'], 'middle')).toEqual({ select: null, onClick: 'bottom' });
});
it('changes nothing on a click when the selected object is alone there', () => {
expect(pressOn(['only'], ['only'], 'only')).toEqual({ select: null, onClick: null });
});
it('is nothing on empty space', () => {
expect(pressOn([], ['top'], 'top')).toBeNull();
});
});
describe('what a right-click opens the menu for (#204)', () => {
const stack = ['top', 'middle', 'bottom'];
it('selects the topmost first when nothing selected is under the pointer', () => {
expect(menuTargetOf(stack, ['elsewhere'], 'elsewhere')).toEqual({ id: 'top', select: true });
});
it('keeps a selection hidden under others and opens on it', () => {
expect(menuTargetOf(stack, ['elsewhere', 'bottom'], 'elsewhere')).toEqual({
id: 'bottom',
select: false,
});
});
it('prefers the primary one of a group', () => {
expect(menuTargetOf(stack, ['top', 'middle'], 'middle')).toEqual({ id: 'middle', select: false });
});
it('opens none on empty space', () => {
expect(menuTargetOf([], ['top'], 'top')).toBeNull();
});
});