fix(ui): show the current Behavior start apart when the filter misses it

With a filter the Start field kept the current start under "Matching
operations · N of M" even when it did not match, so the group held one
entry more than its count: "5 of 61" with six entries, the last one
"main · …/manage.py-tpl". It also stood among the suggestions.

A current start that the filter does not match now has a group of its own,
"Current start, not matching the filter", at the top of the list. The
suggestions and the matches hold only matches, so each count equals its
entries. A current start that matches stays in its place.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 14:56:44 +02:00
1 parent 0c79dd2c5d
commit ddf7c70fd2
7 files changed
+136 -8

No files matched your search

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
/*
* Review of K43: with a filter, "Matching operations · 5 of 61" held six
* entries, the last the current start that did not match. The current start
* now has a group of its own, and the count of every group equals its
* entries.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import BehaviorJourney, { type BehaviorJourneyProps } from './BehaviorJourney';
import { architectureText } from './strings';
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
vi.mock('./BehaviorSourceEvidence', () => ({ default: () => <div data-source-evidence /> }));
vi.mock('./SystemArchitectureScene', () => ({ default: () => <div data-scene /> }));
const symbol = (id: number, name = `operation${id}`, file_path = `src/part${id % 2}.ts`): SystemSymbol => ({ id, name, qualified_name: `sample.${name}`, label: 'Function',
component_id: `component${id % 2}`, file_path, start_line: id });
const LOADDATA = 'django/core/management/commands/loaddata.py';
const entries = [symbol(1), symbol(21, 'handle', 'django/core/management/commands/makemigrations.py'), symbol(22, 'handle', LOADDATA), symbol(23, 'as_sql', 'django/db/models/fields/json.py')];
function fixture(): SystemProjection {
const nodes = [symbol(1), symbol(2)];
return { schema_version: 1, status: 'ready', kind: 'static_projection', complete: true, components: [], dependencies: [], cycles: [], entrypoints: [nodes[0]!],
paths: [{ entrypoint_id: 1, nodes, edges: [{ id: 10, source_id: 1, target_id: 2, type: 'CALLS', callsite: { file_path: 'src/part1.ts', line: 20 } }] }],
totals: {}, limits: {}, warnings: [] };
}
let container: HTMLDivElement, root: Root;
beforeEach(() => { (globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); document.body.append(container); root = createRoot(container); });
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
const select = () => container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')!;
const filterField = () => container.querySelector<HTMLInputElement>('input[aria-label="Filter operations"]')!;
const groups = () => [...select().querySelectorAll('optgroup')].map(group => ({ label: group.label,
options: [...group.querySelectorAll('option')].filter(option => !option.disabled).map(option => option.textContent) }));
async function type(value: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(filterField(), value);
filterField().dispatchEvent(new Event('input', { bubbles: true }));
});
}
it('K43: the current start that does not match the filter stands in a group of its own, and each count equals its entries', async () => {
const data = fixture();
const props: BehaviorJourneyProps = { project: 'sample', generation: 'g1', data, entries, suggestedEntries: [1, 21],
targets: [{ ...data.paths[0]!.nodes[1]!, distance: 1 }], entryId: 1, active: true, pending: false, filter: '',
onRequest: vi.fn(), onRefresh: vi.fn(), onNavigate: vi.fn(), onSelectSymbol: vi.fn() };
await act(async () => root.render(<BehaviorJourney {...props} />));
await type('loaddata');
expect(groups()).toEqual([
{ label: architectureText.behaviorStart.current, options: ['operation1 · src/part1.ts'] },
{ label: 'Matching operations · 1 of 4', options: [`handle · ${LOADDATA}`] },
]);
expect(architectureText.behaviorStart.current).toBe('Current start, not matching the filter');
// The field still shows the current start.
expect(select().value).toBe('1');
expect(select().selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts');
// A filter the current start matches: no group of its own, it stands among the matches.
await type('operation1');
expect(groups()).toEqual([
{ label: 'Suggested', options: ['operation1 · src/part1.ts'] },
{ label: 'Matching operations · 1 of 4', options: ['operation1 · src/part1.ts'] },
]);
// Nothing matches: the current start alone stands apart, the matches say so.
await type('no such operation');
expect(groups().map(group => group.label)).toEqual([architectureText.behaviorStart.current, 'Matching operations · 0 of 4']);
expect(select().querySelector('optgroup:last-child option:last-child')?.textContent).toBe('No operation matches "no such operation"');
});
@@ -215,13 +215,16 @@ describe('Behavior start field', () => {
const props = await render(fixture(), { entries, suggestedEntries: [1, 21] });
await type('loaddata');
expect(group(1).label).toBe('Matching operations · 1 of 4');
expect(options(1)).toEqual([`handle · ${LOADDATA}`, 'operation1 · src/part1.ts']);
expect(options(1)).toEqual([`handle · ${LOADDATA}`]);
// The chosen start stands apart while the filter does not match it (review of K43).
expect(group(0).label).toBe('Current start, not matching the filter');
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
expect(select().value).toBe('1');
await act(async () => { select().value = '22'; select().dispatchEvent(new Event('change', { bubbles: true })); });
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 22, file_path: LOADDATA }), undefined, {});
await type('no such operation');
expect(options(1)).toEqual(['operation1 · src/part1.ts', 'No operation matches "no such operation"']);
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
expect(options(1)).toEqual(['No operation matches "no such operation"']);
expect(group(1).querySelector('option:last-child')?.hasAttribute('disabled')).toBe(true);
});
});
@@ -181,6 +181,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
<input type="search" aria-label={startText.filter} placeholder={startText.filterPlaceholder} value={startQuery} onChange={event => setStartQuery(event.target.value)} />
<select aria-label={startText.field} value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
<option value="">{startText.choose}</option>
{startChoices.current && <optgroup label={startText.current}><option value={startChoices.current.id}>{startChoices.current.label}</option></optgroup>}
{startChoices.suggested.length > 0 && <optgroup label={startText.suggested}>{startChoices.suggested.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}</optgroup>}
<optgroup label={startQuery.trim() ? startText.matching(startChoices.matching, startChoices.total) : startText.all(startChoices.total)}>
{startChoices.all.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
@@ -0,0 +1,44 @@
import { expect, it } from 'vitest';
import { operationChoices } from './operation-choices';
import type { SystemSymbol } from './system-architecture-source';
/*
* Review of K43: with a filter the current start was kept under "Matching
* operations · N of M" even when it did not match, so the group showed one
* entry more than its count ("5 of 61" with six entries, the last one
* "main · …/manage.py-tpl"). The kept start now stands apart, and every
* group holds exactly what it says.
*/
const operation = (id: number, name: string, file_path: string): SystemSymbol =>
({ id, name, qualified_name: '', label: '', file_path, component_id: '' });
const entries = [
operation(1, 'main', 'django/conf/project_template/manage.py-tpl'),
operation(2, '__init__', 'django/db/models/aggregates.py'),
operation(3, '__init__', 'django/forms/fields.py'),
operation(4, 'handle', 'django/core/management/commands/loaddata.py'),
];
it('K43: a current start that does not match the filter stands apart, not among the matches', () => {
const choices = operationChoices(entries, { query: '__init__', keep: 1, suggested: [1, 4] });
expect(choices.current?.id).toBe(1);
expect(choices.current?.label).toBe('main · django/conf/project_template/manage.py-tpl');
expect(choices.all.map(item => item.id)).toEqual([2, 3]);
expect(choices.all).toHaveLength(choices.matching);
// The suggestions are filtered the same way: the current start is not repeated there either.
expect(choices.suggested).toEqual([]);
});
it('K43: a current start that matches stays in its place, and there is no separate entry', () => {
const choices = operationChoices(entries, { query: 'handle', keep: 4, suggested: [1, 4] });
expect(choices.current).toBeUndefined();
expect(choices.all.map(item => item.id)).toEqual([4]);
expect(choices.suggested.map(item => item.id)).toEqual([4]);
expect(choices.matching).toBe(1);
});
it('K43: without a filter every operation is listed, the current one in its place', () => {
const choices = operationChoices(entries, { keep: 1, suggested: [1, 4] });
expect(choices.current).toBeUndefined();
expect(choices.all).toHaveLength(4);
expect(choices.suggested.map(item => item.id)).toEqual([1, 4]);
});
@@ -64,9 +64,11 @@ describe('Behavior start choices (A4)', () => {
it('narrows to the operations that match every word of the filter, and keeps the chosen one', () => {
expect(labels(operationChoices(scouted, { query: 'loaddata' }).all)).toEqual(['handle · django/core/management/commands/loaddata.py']);
expect(operationChoices(scouted, { query: 'handle MIGRATE' }).all.map(item => item.id)).toEqual([5]);
// The chosen start that does not match stands apart (review of K43), so the matches hold what their count says.
const kept = operationChoices(scouted, { query: 'loaddata', keep: 1, suggested: [1, 3] });
expect(kept.all.map(item => item.id)).toEqual([4, 1]);
expect(kept.suggested.map(item => item.id)).toEqual([1]);
expect(kept.all.map(item => item.id)).toEqual([4]);
expect(kept.suggested.map(item => item.id)).toEqual([]);
expect(kept.current?.id).toBe(1);
expect(kept.matching).toBe(1);
// A chosen start that matches counts as a match (measured in the browser: "0 of 61" after picking loaddata).
expect(operationChoices(scouted, { query: 'loaddata', keep: 4 }).matching).toBe(1);
@@ -12,7 +12,9 @@
* the class is the same too, and their position when the index names
* neither;
* - a filter keeps the operations whose name, class or path contain every
* word typed, and always the chosen one, so the field can still show it.
* word typed. The chosen one stays offered so the field can still show it,
* and when it does not match it stands apart (`current`): among the matches
* it made "5 of 61" a group of six (review of K43).
*/
import type { SystemSymbol } from './system-architecture-source';
import { architectureText } from './strings';
@@ -21,6 +23,8 @@ const text = architectureText.behaviorStart;
export interface OperationChoice { id: number; label: string; symbol: SystemSymbol }
export interface OperationChoices {
/** The chosen start while a filter it does not match is set; it is in neither list then. */
current?: OperationChoice;
suggested: OperationChoice[];
all: OperationChoice[];
/** How many operations there are, and how many of them match the filter (the kept one only when it matches). */
@@ -71,13 +75,14 @@ export function operationChoices(entries: SystemSymbol[], { suggested = [], quer
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
const matches = (entry: SystemSymbol) => words.every(word => `${labels.get(entry.id)} ${entry.qualified_name}`.toLocaleLowerCase().includes(word));
const choice = (symbol: SystemSymbol): OperationChoice => ({ id: symbol.id, label: labels.get(symbol.id)!, symbol });
const shown = (entry: SystemSymbol) => entry.id === keep || matches(entry);
const byId = new Map(entries.map(entry => [entry.id, entry]));
const offered = [...new Set(suggested)].filter(id => byId.has(id));
const kept = keep === undefined ? undefined : byId.get(keep);
return {
...(kept && !matches(kept) ? { current: choice(kept) } : {}),
// Suggestions that name every operation would only repeat the list.
suggested: offered.length >= entries.length ? [] : offered.flatMap(id => shown(byId.get(id)!) ? [choice(byId.get(id)!)] : []),
all: entries.filter(shown).sort(compare).map(choice),
suggested: offered.length >= entries.length ? [] : offered.flatMap(id => matches(byId.get(id)!) ? [choice(byId.get(id)!)] : []),
all: entries.filter(matches).sort(compare).map(choice),
total: entries.length,
matching: entries.filter(matches).length,
};
+2
View File
@@ -108,6 +108,8 @@ export const architectureText = {
filter: 'Filter operations',
filterPlaceholder: 'Filter operations…',
suggested: 'Suggested',
/** The chosen start while the filter does not match it (review of K43): apart from the matches, so their count holds. */
current: 'Current start, not matching the filter',
all: (total: number) => `All operations · ${total.toLocaleString()}`,
matching: (shown: number, total: number) => `Matching operations · ${shown.toLocaleString()} of ${total.toLocaleString()}`,
none: (query: string) => `No operation matches "${query}"`,