Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions server/src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,14 @@ const VIRTUAL_EID_FAMILY: Record<number, string> = { 1: 'GLOBAL', 2: 'CONTROLLER
const eidFamily = (map?: Record<number, string>) => (eid: number): string | undefined => map?.[eid] ?? VIRTUAL_EID_FAMILY[eid];

// Parse a variant/page selector `value` ("10,11,12") into the numeric block-type values it activates.
// Blank segments are dropped, so an EMPTY value ("") parses to NO values rather than to [0]: the editor
// writes a blank value on a group's catch-all page (the default for every type its explicit siblings
// don't name), and `Number('')` is 0 — without the blank filter that default reads as "type 0 only",
// which both hides it from every other type and duplicates the type-0 page (see filterPagesBySelector).
const parseSelectorValues = (value: string | null | undefined): number[] =>
value == null ? [] : value.split(',').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n));
value == null
? []
: value.split(',').map((s) => s.trim()).filter((s) => s !== '').map(Number).filter((n) => Number.isFinite(n));

// Pick the block-type / firmware variant that matches the block's CURRENT type value:
// 1. variants whose selector `value` list contains typeValue win (the normal per-type case);
Expand Down Expand Up @@ -130,24 +136,32 @@ const preferNewestFw = <T extends { fw?: EditorFwRange }>(items: T[]): T[] => {
};

// Selector filter for one group of same-named pages: a page with no selector value is always kept; a
// selector-gated page is kept iff the block's CURRENT selector value is in its value list. When the
// current value is unknown (selector not resolvable) we never include the whole set — prefer the page
// whose list contains the block's type value, else the first gated page in editor order.
// selector-gated page is kept iff the block's CURRENT selector value is in its value list. A gated page
// with an EMPTY value list is the group's DEFAULT — the editor's catch-all, applying to every selector
// value its explicit siblings don't name (the Amp block ends each of its Ideal/Authentic groups with
// one; without it, the ~2/3 of amp models with no explicit Ideal page get no Ideal tab at all). It is
// used only when nothing explicit matches. When the current value is unknown (selector not resolvable)
// we never include the whole set — prefer the page whose list contains the block's type value, else the
// group's default, else the first explicitly-gated page in editor order.
const filterPagesBySelector = (
group: EditorLayoutPage[],
typeValue: number | undefined,
selectors?: SelectorValues,
): EditorLayoutPage[] => {
const gated = group.filter((p) => p.selectorParamName != null && p.value != null);
if (!gated.length) return group; // nothing selector-gated → all pages always apply
const ungated = group.filter((p) => p.selectorParamName == null || p.value == null);
const cur = selectors?.(gated[0]!.selectorParamName!);
const isGated = (p: EditorLayoutPage) => p.selectorParamName != null && p.value != null;
const gated = group.filter((p) => isGated(p) && parseSelectorValues(p.value).length > 0);
const dflt = group.filter((p) => isGated(p) && parseSelectorValues(p.value).length === 0);
if (!gated.length && !dflt.length) return group; // nothing selector-gated → all pages always apply
const ungated = group.filter((p) => !isGated(p));
const cur = selectors?.((gated[0] ?? dflt[0])!.selectorParamName!);
if (cur != null) {
// known current value → strict membership; a group that matches nothing contributes no page
return [...ungated, ...gated.filter((p) => parseSelectorValues(p.value).includes(cur))];
// known current value → strict membership, falling back to the group's default page
const hit = gated.filter((p) => parseSelectorValues(p.value).includes(cur));
return [...ungated, ...(hit.length ? hit : dflt)];
}
const byType = typeValue != null ? gated.filter((p) => parseSelectorValues(p.value).includes(typeValue)) : [];
return [...ungated, ...(byType.length ? byType : [gated[0]!])];
const fallback = dflt.length ? dflt : gated.length ? [gated[0]!] : [];
return [...ungated, ...(byType.length ? byType : fallback)];
};

// Drop controls that the newest firmware would hide: a control gated with an `lt` (only firmware < X)
Expand Down
51 changes: 48 additions & 3 deletions server/test/drivers/layouts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,56 @@ const checks: Array<{ name: string; ok: () => boolean }> = [
const l = fm3.layoutFor('DISTORT', 29, sel); if (!l) return false;
const names = l.pages.map((p) => p.name);
return new Set(names).size === names.length; } },
{ name: 'FM3 amp: a non-matching selector group contributes no page (strict membership)', ok: () => {
// 29 lives in an 'Authentic' page but not in any 'Ideal' page → Ideal must be dropped entirely.
{ name: "FM3 amp: a model with no explicit Ideal page still gets the group's catch-all Ideal page", ok: () => {
// 29 lives in an explicit 'Authentic' page but in no explicit 'Ideal' page. The Ideal group ends
// with the editor's catch-all (blank `value`), which is what the editor shows for every model its
// explicit siblings don't name — so exactly one Ideal page is served, not zero.
const sel: SelectorValues = (n) => (n === 'DISTORT_TYPE' ? 29 : undefined);
const l = fm3.layoutFor('DISTORT', 29, sel);
return !!l && l.pages.filter((p) => p.name === 'Ideal').length === 0; } },
const ideal = l?.pages.filter((p) => p.name === 'Ideal') ?? [];
return ideal.length === 1 && ideal[0]!.value === '' && ideal[0]!.rows.length > 0; } },
{ name: 'FM3 amp: an explicit selector match wins over the catch-all (model 6)', ok: () => {
// 6 IS named by an explicit Ideal page → that page is served, never the blank-value default.
const sel: SelectorValues = (n) => (n === 'DISTORT_TYPE' ? 6 : undefined);
const l = fm3.layoutFor('DISTORT', 6, sel);
const ideal = l?.pages.filter((p) => p.name === 'Ideal') ?? [];
return ideal.length === 1 && (ideal[0]!.value ?? '').split(',').includes('6'); } },
{ name: 'FM3 amp: model 0 does not duplicate a group (catch-all is not "value 0")', ok: () => {
// `Number('')` is 0, so a blank value list once read as "model 0" — serving BOTH the explicit
// model-0 page and the catch-all, which Axis rendered as duplicate 'Ideal'/'Ideal 2' tabs.
const sel: SelectorValues = (n) => (n === 'DISTORT_TYPE' ? 0 : undefined);
const l = fm3.layoutFor('DISTORT', 0, sel); if (!l) return false;
const names = l.pages.map((p) => p.name);
return new Set(names).size === names.length
&& names.filter((n) => n === 'Ideal').length === 1
&& names.filter((n) => n === 'Authentic').length === 1; } },
{ name: 'every FM3 amp model is served both an Authentic and an Ideal page', ok: () => {
// The regression this guards: only the 124 models named by an explicit Ideal page got the tab.
for (let t = 0; t < 331; t++) {
const sel: SelectorValues = (n) => (n === 'DISTORT_TYPE' ? t : undefined);
const l = fm3.layoutFor('DISTORT', t, sel); if (!l) return false;
if (l.pages.filter((p) => p.name === 'Ideal').length !== 1) return false;
if (l.pages.filter((p) => p.name === 'Authentic').length !== 1) return false;
}
return true; } },
{ name: 'catch-all page applies only when no explicit sibling matches (synthetic)', ok: () => {
const pages: EditorLayoutPage[] = [
{ name: 'P', rows: [], selectorParamName: 'SEL', value: '1,2' },
{ name: 'P', rows: [], selectorParamName: 'SEL', value: '' },
];
const hit = resolveLayoutPages(pages, undefined, (n) => (n === 'SEL' ? 2 : undefined));
const miss = resolveLayoutPages(pages, undefined, (n) => (n === 'SEL' ? 9 : undefined));
return hit.length === 1 && hit[0]!.value === '1,2' && miss.length === 1 && miss[0]!.value === ''; } },
{ name: 'a group with no catch-all still contributes no page when nothing matches (synthetic)', ok: () => {
const pages: EditorLayoutPage[] = [{ name: 'P', rows: [], selectorParamName: 'SEL', value: '1,2' }];
return resolveLayoutPages(pages, undefined, (n) => (n === 'SEL' ? 9 : undefined)).length === 0; } },
{ name: 'unknown selector value prefers the catch-all over the first gated page (synthetic)', ok: () => {
const pages: EditorLayoutPage[] = [
{ name: 'P', rows: [], selectorParamName: 'SEL', value: '1,2' },
{ name: 'P', rows: [], selectorParamName: 'SEL', value: '' },
];
const out = resolveLayoutPages(pages); // no typeValue, no selectors
return out.length === 1 && out[0]!.value === ''; } },
{ name: 'FM3 amp: unknown selector value never serves ALL — one page per same-named group', ok: () => {
const l = fm3.layoutFor('DISTORT'); if (!l) return false; // no typeValue, no selectors
const byName = new Map<string, number>();
Expand Down