5 changed files with 292 additions and 56 deletions
@ -1,34 +0,0 @@
@@ -1,34 +0,0 @@
|
||||
# CLAUDE.md |
||||
|
||||
## Project Structure |
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application: |
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw` |
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library |
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils` |
||||
- **`examples/`** - Integration examples (NextJS, browser script) |
||||
|
||||
## Development Workflow |
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features |
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features |
||||
3. **Testing**: Always run `yarn test:update` before committing |
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript |
||||
|
||||
## Development Commands |
||||
|
||||
```bash |
||||
yarn test:typecheck # TypeScript type checking |
||||
yarn test:update # Run all tests (with snapshot updates) |
||||
yarn fix # Auto-fix formatting and linting issues |
||||
``` |
||||
|
||||
## Architecture Notes |
||||
|
||||
### Package System |
||||
|
||||
- Uses Yarn workspaces for monorepo management |
||||
- Internal packages use path aliases (see `vitest.config.mts`) |
||||
- Build system uses esbuild for packages, Vite for the app |
||||
- TypeScript throughout with strict configuration |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
/** |
||||
* Utilities for stable search results ordering |
||||
* Ensures search results remain in consistent order despite scene updates |
||||
*/ |
||||
|
||||
import type { SearchMatchItem } from "./SearchMenu"; |
||||
|
||||
/** |
||||
* Sorts search matches by element.id and then by match index |
||||
* Ensures stable ordering regardless of scene changes or element repositioning |
||||
* |
||||
* @param items - Array of search match items to sort |
||||
* @returns - New sorted array maintaining stable order |
||||
*/ |
||||
export const getStableMatches = (items: SearchMatchItem[]): SearchMatchItem[] => { |
||||
return [...items].sort((a, b) => { |
||||
// First, sort by element ID (stable identifier)
|
||||
const idComparison = a.element.id.localeCompare(b.element.id); |
||||
if (idComparison !== 0) { |
||||
return idComparison; |
||||
} |
||||
// If same element, sort by match index within the text
|
||||
return (a.index ?? 0) - (b.index ?? 0); |
||||
}); |
||||
}; |
||||
@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest"; |
||||
import type { SearchMatchItem } from "../SearchMenu"; |
||||
import { getStableMatches } from "../SearchMenu.utils"; |
||||
|
||||
/** |
||||
* Integration tests for SearchMenu MatchList stable ordering |
||||
* Verifies that results maintain consistent order despite scene updates |
||||
*/ |
||||
|
||||
describe("SearchMenu - Stable Ordering Integration", () => { |
||||
const createMockSearchMatchItem = ( |
||||
elementId: string, |
||||
matchIndex: number = 0, |
||||
previewText: string = elementId, |
||||
): SearchMatchItem => ({ |
||||
element: { |
||||
id: elementId, |
||||
type: "text", |
||||
text: previewText, |
||||
} as any, |
||||
searchQuery: "test" as any, |
||||
index: matchIndex, |
||||
preview: { |
||||
indexInSearchQuery: 0, |
||||
previewText: previewText, |
||||
moreBefore: false, |
||||
moreAfter: false, |
||||
}, |
||||
matchedLines: [], |
||||
}); |
||||
|
||||
it("getStableMatches produz ordem consistente após múltiplas reordenações de entrada", () => { |
||||
const items1 = [ |
||||
createMockSearchMatchItem("element-3"), |
||||
createMockSearchMatchItem("element-1"), |
||||
createMockSearchMatchItem("element-2"), |
||||
]; |
||||
|
||||
const items2 = [ |
||||
createMockSearchMatchItem("element-2"), |
||||
createMockSearchMatchItem("element-3"), |
||||
createMockSearchMatchItem("element-1"), |
||||
]; |
||||
|
||||
const sorted1 = getStableMatches(items1); |
||||
const sorted2 = getStableMatches(items2); |
||||
|
||||
const ids1 = sorted1.map((m) => m.element.id); |
||||
const ids2 = sorted2.map((m) => m.element.id); |
||||
|
||||
expect(ids1).toEqual(ids2); |
||||
expect(ids1).toEqual(["element-1", "element-2", "element-3"]); |
||||
}); |
||||
|
||||
it("getStableMatches mantém ordem quando múltiplos matches no mesmo elemento existem", () => { |
||||
const items = [ |
||||
createMockSearchMatchItem("text-b", 2), |
||||
createMockSearchMatchItem("text-b", 0), |
||||
createMockSearchMatchItem("text-a", 0), |
||||
createMockSearchMatchItem("text-b", 1), |
||||
]; |
||||
|
||||
const sorted = getStableMatches(items); |
||||
|
||||
const result = sorted.map((m) => `${m.element.id}[${m.index}]`); |
||||
expect(result).toEqual([ |
||||
"text-a[0]", |
||||
"text-b[0]", |
||||
"text-b[1]", |
||||
"text-b[2]", |
||||
]); |
||||
}); |
||||
|
||||
it("simula cenário: elemento é movido no canvas (scene update) enquanto busca está ativa", () => { |
||||
// Estado inicial: busca encontra A, B, C
|
||||
const initialMatches = [ |
||||
createMockSearchMatchItem("element-a", 0, "test alpha"), |
||||
createMockSearchMatchItem("element-b", 0, "test beta"), |
||||
createMockSearchMatchItem("element-c", 0, "test gamma"), |
||||
]; |
||||
|
||||
const sortedInitial = getStableMatches(initialMatches); |
||||
const initialOrder = sortedInitial.map((m) => m.element.id); |
||||
|
||||
// Simulate: element B é movido (x, y muda) na scene
|
||||
// Callbacks de search disparam novamente com mesmos elementos mas potencialmente reordenados
|
||||
const afterElementMove = [ |
||||
createMockSearchMatchItem("element-c", 0, "test gamma"), // pode aparecer em ordem diferente
|
||||
createMockSearchMatchItem("element-a", 0, "test alpha"), |
||||
createMockSearchMatchItem("element-b", 0, "test beta"), |
||||
]; |
||||
|
||||
const sortedAfterMove = getStableMatches(afterElementMove); |
||||
const orderAfterMove = sortedAfterMove.map((m) => m.element.id); |
||||
|
||||
// A ordem deve permanecer a mesma, apesar da reordenação de entrada
|
||||
expect(orderAfterMove).toEqual(initialOrder); |
||||
expect(orderAfterMove).toEqual(["element-a", "element-b", "element-c"]); |
||||
}); |
||||
|
||||
it("verifica que IDs são chaves únicas estáveis sem colisão de concatenação", () => { |
||||
// Teste para evitar colisões do tipo: "elem1" + "2" == "elem" + "12"
|
||||
const items = [ |
||||
createMockSearchMatchItem("elem1", 2), // sem separador: "elem12"
|
||||
createMockSearchMatchItem("elem", 12), // sem separador: "elem12" (colisão!)
|
||||
]; |
||||
|
||||
const sorted = getStableMatches(items); |
||||
|
||||
// Com ordenação por ID e index, nunca haverá confusão
|
||||
expect(sorted[0].element.id).toBe("elem"); |
||||
expect(sorted[1].element.id).toBe("elem1"); |
||||
}); |
||||
|
||||
it("trata edge case: nenhum match", () => { |
||||
const matches: SearchMatchItem[] = []; |
||||
const sorted = getStableMatches(matches); |
||||
expect(sorted).toEqual([]); |
||||
}); |
||||
|
||||
it("trata edge case: um único match", () => { |
||||
const matches = [createMockSearchMatchItem("single-element", 0)]; |
||||
const sorted = getStableMatches(matches); |
||||
expect(sorted).toHaveLength(1); |
||||
expect(sorted[0].element.id).toBe("single-element"); |
||||
}); |
||||
|
||||
it("preserva ordem idêntica em chamadas sucessivas (idempotência)", () => { |
||||
const original = [ |
||||
createMockSearchMatchItem("z-element", 0), |
||||
createMockSearchMatchItem("a-element", 1), |
||||
createMockSearchMatchItem("m-element", 0), |
||||
]; |
||||
|
||||
const round1 = getStableMatches(original); |
||||
const round2 = getStableMatches(round1); |
||||
const round3 = getStableMatches(round2); |
||||
|
||||
const ids1 = round1.map((m) => `${m.element.id}[${m.index}]`).join(","); |
||||
const ids2 = round2.map((m) => `${m.element.id}[${m.index}]`).join(","); |
||||
const ids3 = round3.map((m) => `${m.element.id}[${m.index}]`).join(","); |
||||
|
||||
expect(ids1).toBe(ids2); |
||||
expect(ids2).toBe(ids3); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest"; |
||||
import type { SearchMatchItem } from "../SearchMenu"; |
||||
import { getStableMatches } from "../SearchMenu.utils"; |
||||
|
||||
describe("getStableMatches", () => { |
||||
const createMockMatch = ( |
||||
elementId: string, |
||||
matchIndex: number = 0, |
||||
): SearchMatchItem => ({ |
||||
element: { |
||||
id: elementId, |
||||
type: "text", |
||||
} as any, |
||||
searchQuery: "test" as any, |
||||
index: matchIndex, |
||||
preview: { |
||||
indexInSearchQuery: 0, |
||||
previewText: "test", |
||||
moreBefore: false, |
||||
moreAfter: false, |
||||
}, |
||||
matchedLines: [], |
||||
}); |
||||
|
||||
it("ordena consistentemente por element.id quando os matches chegam em qualquer ordem", () => { |
||||
const matches = [ |
||||
createMockMatch("C", 0), |
||||
createMockMatch("A", 0), |
||||
createMockMatch("B", 0), |
||||
]; |
||||
|
||||
const sorted = getStableMatches(matches); |
||||
|
||||
expect(sorted.map((m) => m.element.id)).toEqual(["A", "B", "C"]); |
||||
}); |
||||
|
||||
it("ordena por element.id e depois por index dentro do mesmo elemento", () => { |
||||
const matches = [ |
||||
createMockMatch("B", 0), |
||||
createMockMatch("A", 1), |
||||
createMockMatch("A", 0), |
||||
createMockMatch("B", 1), |
||||
]; |
||||
|
||||
const sorted = getStableMatches(matches); |
||||
|
||||
const result = sorted.map((m) => `${m.element.id}_${m.index}`); |
||||
expect(result).toEqual(["A_0", "A_1", "B_0", "B_1"]); |
||||
}); |
||||
|
||||
it("retorna cópia não-mutante do array original", () => { |
||||
const original = [ |
||||
createMockMatch("B", 0), |
||||
createMockMatch("A", 0), |
||||
]; |
||||
const originalOrder = original.map((m) => m.element.id); |
||||
|
||||
const sorted = getStableMatches(original); |
||||
|
||||
// Verifica se original não foi modificado
|
||||
expect(original.map((m) => m.element.id)).toEqual(originalOrder); |
||||
// Verifica se resultado está ordenado
|
||||
expect(sorted.map((m) => m.element.id)).toEqual(["A", "B"]); |
||||
}); |
||||
|
||||
it("mantém ordem estável mesmo se reordenado múltiplas vezes", () => { |
||||
const originalMatches = [ |
||||
createMockMatch("C", 0), |
||||
createMockMatch("B", 0), |
||||
createMockMatch("A", 0), |
||||
]; |
||||
|
||||
const sorted1 = getStableMatches(originalMatches); |
||||
const sorted2 = getStableMatches(sorted1); |
||||
const sorted3 = getStableMatches(sorted2); |
||||
|
||||
const result1 = sorted1.map((m) => m.element.id); |
||||
const result2 = sorted2.map((m) => m.element.id); |
||||
const result3 = sorted3.map((m) => m.element.id); |
||||
|
||||
expect(result1).toEqual(["A", "B", "C"]); |
||||
expect(result2).toEqual(["A", "B", "C"]); |
||||
expect(result3).toEqual(["A", "B", "C"]); |
||||
}); |
||||
|
||||
it("trata array vazio", () => { |
||||
const sorted = getStableMatches([]); |
||||
expect(sorted).toEqual([]); |
||||
}); |
||||
|
||||
it("trata array com um único elemento", () => { |
||||
const matches = [createMockMatch("A", 0)]; |
||||
const sorted = getStableMatches(matches); |
||||
expect(sorted).toEqual(matches); |
||||
}); |
||||
}); |
||||
Loading…
Reference in new issue