Skip to content

refactor: improve GraphSelectionModel implementation, jsdoc and tests#1055

Open
redfish4ktc wants to merge 5 commits intomainfrom
refactor/improve_graph_selection_model
Open

refactor: improve GraphSelectionModel implementation, jsdoc and tests#1055
redfish4ktc wants to merge 5 commits intomainfrom
refactor/improve_graph_selection_model

Conversation

@redfish4ktc
Copy link
Copy Markdown

@redfish4ktc redfish4ktc commented May 4, 2026

Improve confidence and maintainability of GraphSelectionModel and SelectionChange via tests, code cleanup,
and documentation, plus a small bug fix uncovered during review.

Why

  • Both classes had no unit-test coverage. With selection logic central to interaction and undo, regressions
    were silent.
  • The implementation mixed legacy imperative loops with subtle quirks (a [null] guard that only inspected
    the first array position, parameter reassignment, copy-paste JSDoc on SelectionChange) — the kind of code
    that's risky to change without a safety net.

What

  • Tests: 44 unit tests covering the public API of both classes, edge cases (empty arrays, null entries,
    single-selection mode, swap/round-trip on SelectionChange) and the UNDO/CHANGE event payloads. 100%
    branch coverage on both files.
  • Bug fix: changeSelection now filters null/undefined entries from both arrays before applying. The
    previous guard (added && added.length > 0 && added[0]) only inspected position 0, so [realCell, null]
    silently leaked null into the selection state. The new filter is strictly stronger and rejects nulls at
    any position.
  • Implementation cleanup: imperative for-loops replaced with filter/find; setCells and addCells now share
    the same early-return shape with no parameter reassignment and no redundant isCellSelectable calls;
    every method declares an explicit return type.
  • Documentation: rewrite SelectionChange JSDoc (was copy-paste from CurrentRootChange and described root
    changes), and fix broken {@link change} / {@link select} references in GraphSelectionModel to point
    at the actual fired event (InternalEvent.CHANGE).
  • Project rules: introduce a TypeScript section in coding-practices.md grouping the existing Type-Only
    Imports rule with a new Explicit Return Types rule (improves IDE introspection and speeds up TS
    inference). Both rules are applied throughout the changed files.

Summary by CodeRabbit

  • Tests

    • Added comprehensive tests for selection behavior, undoable selection changes, event emission, deduplication, and regression coverage.
  • Refactor

    • Modernized the selection model with clearer selection logic and explicit TypeScript method signatures.
  • Chores

    • Updated architecture coding-practices with TypeScript rules (type-only imports and explicit return types) and clarified selection-change documentation.

Improve confidence and maintainability of GraphSelectionModel and SelectionChange via tests, code cleanup,
and documentation, plus a small bug fix uncovered during review.

Why
- Both classes had no unit-test coverage. With selection logic central to interaction and undo, regressions
  were silent.
- The implementation mixed legacy imperative loops with subtle quirks (a [null] guard that only inspected
  the first array position, parameter reassignment, copy-paste JSDoc on SelectionChange) — the kind of code
  that's risky to change without a safety net.

What
- Tests: 44 unit tests covering the public API of both classes, edge cases (empty arrays, null entries,
  single-selection mode, swap/round-trip on SelectionChange) and the UNDO/CHANGE event payloads. 100%
  branch coverage on both files.
- Bug fix: changeSelection now filters null/undefined entries from both arrays before applying. The
  previous guard `(added && added.length > 0 && added[0])` only inspected position 0, so `[realCell, null]`
  silently leaked null into the selection state. The new filter is strictly stronger and rejects nulls at
  any position.
- Implementation cleanup: imperative for-loops replaced with filter/find; setCells and addCells now share
  the same early-return shape with no parameter reassignment and no redundant isCellSelectable calls;
  every method declares an explicit return type.
- Documentation: rewrite SelectionChange JSDoc (was copy-paste from CurrentRootChange and described root
  changes), and fix broken `{@link change}` / `{@link select}` references in GraphSelectionModel to point
  at the actual fired event (InternalEvent.CHANGE).
- Project rules: introduce a TypeScript section in coding-practices.md grouping the existing Type-Only
  Imports rule with a new Explicit Return Types rule (improves IDE introspection and speeds up TS
  inference). Both rules are applied throughout the changed files.
@redfish4ktc redfish4ktc added the refactor Code refactoring label May 4, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 398c93bd-84de-4b23-b45e-6211a3472432

📥 Commits

Reviewing files that changed from the base of the PR and between 79773a1 and 918ec6a.

📒 Files selected for processing (1)
  • packages/core/__tests__/view/GraphSelectionModel.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/tests/view/GraphSelectionModel.test.ts

Walkthrough

Refactors GraphSelectionModel to add explicit TypeScript method signatures and modern array-based selection logic, converts some imports to import type, updates architecture docs with a new TypeScript section (type-only imports + explicit return types), and adds comprehensive tests for GraphSelectionModel and SelectionChange.

Changes

Selection model, undo change, tests & docs

Layer / File(s) Summary
Docs / Policy
.claude/rules/architecture/coding-practices.md
Adds a TypeScript section: mandates import type for type-only imports and explicit return types on exported functions and all class methods (permits simple arrow-callback inference).
Type Imports
packages/core/src/view/undoable-change/SelectionChange.ts
Converts AbstractGraph and Cell imports to import type and updates JSDoc to describe SelectionChange semantics (add/remove swap and event payload naming).
API Signatures / Types
packages/core/src/view/GraphSelectionModel.ts
Adds explicit return types to public/class methods and internal callbacks (: void, : boolean, `: Cell
Core Logic / Behavior
packages/core/src/view/GraphSelectionModel.ts
Rewrites selection operations to use Array helpers (filter, find, includes); single-selection behavior uses getFirstSelectableCell; changeSelection normalizes nullish inputs via inline predicates.
Tests
packages/core/__tests__/view/GraphSelectionModel.test.ts, packages/core/__tests__/view/undoable-change/SelectionChange.test.ts
Adds thorough tests for construction, selection state, getFirstSelectableCell, add/remove/set/clear behaviors (including single-selection), deduplication/filtering, undo/CHANGE event payloads, and SelectionChange execute/swap behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested labels

bug, documentation

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description thoroughly covers the 'Why', 'What', and implementation details, including bug fix rationale, test coverage, and documentation improvements, but does not explicitly reference an issue number or address the PR checklist items. Add an explicit issue reference (closes #xxxx) or clarify if this is a standalone improvement; optionally address the PR checklist items to confirm compliance with the template.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'refactor: improve GraphSelectionModel implementation, jsdoc and tests' directly and clearly reflects the main changes: refactoring GraphSelectionModel, improving JSDoc documentation, and adding tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/core/__tests__/view/GraphSelectionModel.test.ts (1)

244-263: ⚡ Quick win

Missing test case: addCells([alreadySelected]) in single-selection mode should be a no-op.

The test suite covers replacing with a new cell (line 244) and clearing when no candidate is selectable (line 255), but there is no test for passing an already-selected cell in single-selection mode. With the current implementation that case unexpectedly clears the selection (as detailed in the addCells comment on GraphSelectionModel.ts). Adding this test would both document the correct contract and catch the regression:

✅ Proposed additional test
test('single-selection mode is a no-op when the first selectable candidate is already selected', () => {
  const existingCell = new Cell();
  const { model } = createModel();
  seedSelection(model, existingCell);
  model.setSingleSelection(true);
  const events = captureUndoEvents(model);
  model.addCells([existingCell]);
  expect(model.cells).toEqual([existingCell]);
  expect(events).toHaveLength(0);
});

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8ef7d91e-e09a-49f9-96b9-9d584b78484c

📥 Commits

Reviewing files that changed from the base of the PR and between e34e566 and 3a9aeb7.

📒 Files selected for processing (5)
  • .claude/rules/architecture/coding-practices.md
  • packages/core/__tests__/view/GraphSelectionModel.test.ts
  • packages/core/__tests__/view/undoable-change/SelectionChange.test.ts
  • packages/core/src/view/GraphSelectionModel.ts
  • packages/core/src/view/undoable-change/SelectionChange.ts

Comment thread packages/core/__tests__/view/GraphSelectionModel.test.ts Outdated
Comment on lines +182 to 195
addCells(cells: Cell[]): void {
if (this.singleSelection) {
remove = this.cells;

const selectableCell = this.getFirstSelectableCell(cells);
cells = selectableCell ? [selectableCell] : [];
const firstSelectable = this.getFirstSelectableCell(cells);
const toAdd =
firstSelectable && !this.isSelected(firstSelectable) ? [firstSelectable] : [];
this.changeSelection(toAdd, this.cells);
return;
}

const tmp = [];
for (let i = 0; i < cells.length; i += 1) {
if (!this.isSelected(cells[i]) && this.graph.isCellSelectable(cells[i])) {
tmp.push(cells[i]);
}
}

this.changeSelection(tmp, remove);
const toAdd = cells.filter(
(cell) => !this.isSelected(cell) && this.graph.isCellSelectable(cell)
);
this.changeSelection(toAdd, null);
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

addCells single-selection mode unexpectedly clears the selection when the first selectable candidate is already selected.

When firstSelectable is non-null but already selected, !this.isSelected(firstSelectable) is false, so toAdd = []. The call changeSelection([], this.cells) then executes because toRemove = this.cells is non-empty, removing every cell and adding nothing — leaving an empty selection instead of a no-op.

Compare with setCells in single-selection mode, which always passes [firstSelectable] and correctly retains the cell via a remove+re-add cycle.

The fix is to early-return when the first selectable is already the single selected cell, and otherwise replace cleanly:

🐛 Proposed fix
  addCells(cells: Cell[]): void {
    if (this.singleSelection) {
      const firstSelectable = this.getFirstSelectableCell(cells);
-     const toAdd =
-       firstSelectable && !this.isSelected(firstSelectable) ? [firstSelectable] : [];
-     this.changeSelection(toAdd, this.cells);
-     return;
+     if (firstSelectable && this.isSelected(firstSelectable)) {
+       return; // already the selected cell; no-op
+     }
+     this.changeSelection(firstSelectable ? [firstSelectable] : [], this.cells);
+     return;
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This problem previously existed and need to be investigate.
Implementing this in addCells introduce a asymmetry with the setCells method which may also require the same change.
This will eventually managed in another PR

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@redfish4ktc, understood — fixing only addCells without applying the equivalent guard to setCells would leave an inconsistent API surface, so deferring both to a dedicated PR makes sense. Thanks for the context!

Would you like me to open a GitHub issue to track this follow-up work? I can document the asymmetry between addCells and setCells in single-selection mode (the no-op case when the first selectable cell is already selected) so it doesn't get lost.


✏️ Learnings added
Learnt from: redfish4ktc
Repo: maxGraph/maxGraph PR: 1055
File: packages/core/src/view/GraphSelectionModel.ts:182-195
Timestamp: 2026-05-06T10:15:49.149Z
Learning: In `packages/core/src/view/GraphSelectionModel.ts`, the single-selection no-op case (when the first selectable cell in the incoming list is already the selected cell) is a pre-existing bug that affects both `addCells` and `setCells`. Any fix must be applied symmetrically to both methods to avoid API inconsistency; the fix is intentionally deferred to a future PR.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes do it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

gh issue create \
  --repo maxGraph/maxGraph \
  --title "fix(GraphSelectionModel): handle single-selection no-op when first selectable cell is already selected (addCells and setCells)" \
  --body "## Summary

In \`GraphSelectionModel\` (single-selection mode), when the first selectable cell in the incoming list is **already** the currently selected cell, both \`addCells\` and \`setCells\` should be a no-op. Currently neither method short-circuits in this case:

- **\`addCells\`** – \`firstSelectable && !this.isSelected(firstSelectable)\` evaluates to \`false\`, so \`toAdd = []\` but \`toRemove = this.cells\` is non-empty. \`changeSelection([], this.cells)\` fires, clearing the selection instead of doing nothing.
- **\`setCells\`** – always passes \`[firstSelectable]\` into \`changeSelection\`, triggering a remove+re-add cycle even when the cell is already selected. The end state is correct, but a spurious \`UNDO\`/\`CHANGE\` event pair is emitted.

Any fix must be applied **symmetrically** to both methods to avoid API inconsistency.

## Steps to reproduce

1. Enable single-selection mode on a \`GraphSelectionModel\`.
2. Select a cell via \`addCell\` or \`setCell\`.
3. Call \`addCell\` or \`setCell\` again with the **same** cell.
4. Observe: \`addCells\` clears the selection; \`setCells\` fires unnecessary events.

## Expected behaviour

Both calls should be a no-op (selection unchanged, no events fired) when the first selectable candidate is already the selected cell.

## Proposed fix sketch

\`\`\`typescript
// addCells – single-selection branch
const firstSelectable = this.getFirstSelectableCell(cells);
if (firstSelectable && this.isSelected(firstSelectable)) {
  return; // already the selected cell; no-op
}
this.changeSelection(firstSelectable ? [firstSelectable] : [], this.cells);
return;

// setCells – single-selection branch (symmetric guard)
const firstSelectable = this.getFirstSelectableCell(cells);
if (firstSelectable && this.isSelected(firstSelectable) && this.cells.length === 1) {
  return; // already the only selected cell; no-op
}
this.changeSelection(firstSelectable ? [firstSelectable] : [], this.cells);
return;
\`\`\`

## References

- Identified in PR `#1055` (comment: https://github.com/maxGraph/maxGraph/pull/1055#discussion_r3179078963)
- Intentionally deferred from PR `#1055` to keep the fix symmetric and self-contained.

/cc `@redfish4ktc`"

Length of output: 49


@redfish4ktc, done! The follow-up issue has been created: #1056

It documents the symmetric no-op bug in both addCells and setCells under single-selection mode, includes steps to reproduce, the expected behaviour, a proposed fix sketch, and backlinks to this PR and the discussion comment.


🧠 Learnings used
Learnt from: redfish4ktc
Repo: maxGraph/maxGraph PR: 1055
File: packages/core/src/view/GraphSelectionModel.ts:182-195
Timestamp: 2026-05-06T10:15:49.149Z
Learning: In packages/core/src/view/GraphSelectionModel.ts, there is a pre-existing bug in the single-selection no-op path when the first selectable cell in the incoming list is already the selected cell. This affects both addCells and setCells. Any fix must be applied symmetrically to both methods to avoid API inconsistency; the fix is intentionally deferred to a future PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/__tests__/view/GraphSelectionModel.test.ts (1)

264-291: ⚡ Quick win

Prefer a failing/TODO test for known-bug coverage instead of asserting buggy behavior.

These assertions currently encode the wrong contract (model.cells becomes []) as success. Consider marking this case as an expected-failure or TODO so the suite documents the bug without cementing incorrect behavior as “green.”


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93f41e26-79d8-44c3-a2ec-3c708b13f189

📥 Commits

Reviewing files that changed from the base of the PR and between 3a9aeb7 and 48bdbd7.

📒 Files selected for processing (2)
  • packages/core/__tests__/view/GraphSelectionModel.test.ts
  • packages/core/__tests__/view/undoable-change/SelectionChange.test.ts

- Replace captureUndoEvents with captureSelectionEvents, which listens to both InternalEvent.UNDO and
  InternalEvent.CHANGE and returns { undo, change }. Every test that previously checked the UNDO event
  count now also asserts the CHANGE count, validating the chain
  `changeSelection -> SelectionChange.execute -> fireEvent(CHANGE)` stays connected at the integration
  level without duplicating the payload assertions already covered in SelectionChange.test.ts.
- Add a test.each regression sentinel under 'addCell and addCells' for a pre-existing bug uncovered
  during PR review: in single-selection mode, addCells([alreadySelected]) (or with the already-selected
  cell as the first selectable candidate) clears the selection instead of being a no-op. The tests
  assert the CURRENT buggy behavior so the suite stays green; they are tagged 'BUG (#1055):' and link
  the CodeRabbit comment so the follow-up PR finds them. The follow-up will likely silence the no-op on
  both setCells and addCells; tracked in a local WIP doc.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2937fee6-947b-4a76-a646-3f87aba888f3

📥 Commits

Reviewing files that changed from the base of the PR and between 48bdbd7 and 79773a1.

📒 Files selected for processing (1)
  • packages/core/__tests__/view/GraphSelectionModel.test.ts

Comment thread packages/core/__tests__/view/GraphSelectionModel.test.ts Outdated
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 6, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Code refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant