Group the asset panel by category, then by type

The asset panel listed one heading per type, 30 of them side by side.
Now the types sit under five categories, and each type heading reads
like its entry in the + menu.

- ASSET_GROUPS replaces ASSET_GROUP_LABELS and gives every type a name
  and a category: Basics, Visuals, Logics, Physics or Audio.
- The categories keep that fixed order. The groups in a category and
  the assets in a group stay sorted by name.
- A category heading folds like a group heading. A project opens with
  every category open and every group folded, as before.
- Selecting an asset unfolds its category and its group. Folding the
  category of the selected asset lets go of the selection.
- The headings are singular now (Scene, Sprite, Sound FX). The old
  plural names only kept them apart from the + menu entries.
- locate looks in the popup layer first. A click by text meant for a
  menu entry hit the heading of the same name, since the panel is drawn
  first. Below an open menu a click only closes the menu anyway.
- New test hook __gm.assetCategories().

Tests:
- Unit asset-groups: 6 green, counter-check (empty categories kept)
  1 red
- e2e 04: ALL PASSED, 63 checks (1 new). Counter-check with locate
  taking the first hit again: 2 red, the click on Behavior created
  no asset
- Gate: CHECK EXIT 0, 261 files, 4040 tests
- The category names got their final plural form after these runs.
  The unit test ran again on them (6 green), e2e 04 did not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 17:59:08 +02:00
1 parent 8154569405
commit 5e09ba9a50
6 files changed
+239 -107

No files matched your search

+35 -19
View File
@@ -1,13 +1,14 @@
import { describe, expect, it } from 'vitest';
import type { Asset } from '../../src/core/model/Asset';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes';
import { ASSET_GROUP_LABELS, groupAssets } from '../../src/editor/panels/assetGroups';
import { ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes';
import { ASSET_CATEGORIES, ASSET_GROUPS, groupAssets } from '../../src/editor/panels/assetGroups';
let serial = 0;
const asset = (type: AssetType, name: string): Asset =>
({ id: `a${++serial}`, type, name, props: {} }) as unknown as Asset;
const shape = (assets: Asset[]) => groupAssets(assets).map((g) => [g.type, g.assets.map((a) => a.name)]);
const groupsOf = (assets: readonly Asset[]) => groupAssets(assets).flatMap((c) => c.groups);
const shape = (assets: Asset[]) => groupsOf(assets).map((g) => [g.type, g.assets.map((a) => a.name)]);
describe('groupAssets (#176)', () => {
it('puts every asset into the group of its type, and no empty group', () => {
@@ -18,13 +19,36 @@ describe('groupAssets (#176)', () => {
]);
});
it('sorts the groups by their heading, not by type or declaration order', () => {
it('puts the groups under their category, categories in their fixed order and none empty', () => {
const assets = [
asset('song', 'Tune'),
asset('behavior', 'Mover'),
asset('scene', 'Level'),
asset('sprite', 'Hero'),
];
expect(groupAssets(assets).map((c) => [c.category, c.groups.map((g) => g.type)])).toEqual([
['Basics', ['scene']],
['Visuals', ['sprite']],
['Logics', ['behavior']],
['Audio', ['song']],
]);
});
it('sorts the groups in a category by their heading, not by type or declaration order', () => {
const types = [...ASSET_TYPES].reverse();
const headings = groupAssets(types.map((t) => asset(t, 'x'))).map((g) => ASSET_GROUP_LABELS[g.type]);
expect(headings).toEqual([...headings].sort((a, b) => a.localeCompare(b)));
expect(headings).toHaveLength(ASSET_TYPES.length);
// By heading: "State machines" sorts under S, the type key `statemachine` would not tell.
expect(headings.indexOf('Sprites')).toBeLessThan(headings.indexOf('State machines'));
const categories = groupAssets(types.map((t) => asset(t, 'x')));
expect(categories.map((c) => c.category)).toEqual([...ASSET_CATEGORIES]);
for (const { groups } of categories) {
const headings = groups.map((g) => ASSET_GROUPS[g.type].name);
expect(headings).toEqual([...headings].sort((a, b) => a.localeCompare(b)));
}
expect(categories.flatMap((c) => c.groups)).toHaveLength(ASSET_TYPES.length);
// By heading: "State machine" sorts under S, the type key `statemachine` would not tell.
const logic = categories
.find((c) => c.category === 'Logics')!
.groups.map((g) => ASSET_GROUPS[g.type].name);
expect(logic.indexOf('Behavior')).toBeLessThan(logic.indexOf('State machine'));
expect(logic.indexOf('State machine')).toBeLessThan(logic.indexOf('Struct'));
});
it('sorts the assets in a group by name, ignoring case and reading numbers as numbers', () => {
@@ -35,8 +59,8 @@ describe('groupAssets (#176)', () => {
it('keeps the project order among equal names', () => {
const first = asset('sprite', 'Coin');
const second = asset('sprite', 'coin');
expect(groupAssets([first, second])[0]!.assets).toEqual([first, second]);
expect(groupAssets([second, first])[0]!.assets).toEqual([second, first]);
expect(groupsOf([first, second])[0]!.assets).toEqual([first, second]);
expect(groupsOf([second, first])[0]!.assets).toEqual([second, first]);
});
it('leaves the project list alone', () => {
@@ -44,12 +68,4 @@ describe('groupAssets (#176)', () => {
groupAssets(assets);
expect(assets.map((a) => a.name)).toEqual(['B', 'A']);
});
it('names a heading like a + menu entry only where the type is plural anyway', () => {
// A click by text takes the first hit, and the panel is drawn before any menu.
const entries = new Set(Object.values(ASSET_TYPE_LABELS));
// Variables and Particles are already plural; nothing else may collide.
const clashes = Object.values(ASSET_GROUP_LABELS).filter((label) => entries.has(label));
expect(clashes.sort()).toEqual(['Particles', 'Variables']);
});
});