mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
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,
|
||||
};
|
||||
|
||||
@@ -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}"`,
|
||||
|
||||
Reference in new issue
Block a user