diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d2d198ba8..f8d687ed6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,6 +26,11 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + # simlin-mcp's pysimlin_version_matches_latest_tag needs the + # pysimlin-v* tags (setuptools-scm makes the tag the version's + # source of truth); without them the guard skips on every run. + fetch-tags: true - name: Install Rust toolchain run: rustup show diff --git a/CLAUDE.md b/CLAUDE.md index 284383c09..fb31ea5bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,7 @@ This is the most expensive class of error in this repo, because review does not - Public Rust items and non-trivial internal functions should have concise rustdoc describing purpose, key assumptions, and side effects. - When behavior changes, update nearby comments in the same commit so docs and code stay aligned. - If you intentionally remove a comment block, replace it with an updated equivalent when the context is still non-obvious. +- **Documentation is evergreen, NEVER a changelog.** Docs (CLAUDE.md files, `docs/`, rustdoc, docstrings) describe the current state of the code; they never narrate the edit that produced it. "X was removed", "this used to Y", "now does Z", "behaviour is unchanged" are all changelog sentences -- git history is the changelog, and readers dig there when they want it. When you delete or move something, rewrite the surrounding docs as if the code had always been this way. If the old design carried a lesson worth keeping, state it as a standing constraint ("never replace this alias with a second implementation: a hand-maintained copy drifts exactly where the real one is non-trivial"), not as a story about what happened. Citing a GH issue for a load-bearing decision is fine -- an issue number is a pointer, not a narrative. - NEVER add a "Last updated" (or "Last verified") line to a `CLAUDE.md`: it is a perpetual rebase/merge-conflict magnet and goes stale immediately. Describe current state in prose; rely on `git log` / `git blame` for history. ## Development Standards diff --git a/Cargo.lock b/Cargo.lock index 21e9bdfe1..c5b0c7d78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3358,6 +3358,7 @@ dependencies = [ "serde", "serde_json", "simlin-engine", + "simlin-mcp-core", "tempfile", "tokio", ] @@ -3386,6 +3387,7 @@ dependencies = [ "serde_yaml", "simlin-engine", "simlin-mcp-core", + "simlin-serve", "tempfile", "tokio", "tokio-tungstenite", diff --git a/docs/design/ltm--loops-that-matter.md b/docs/design/ltm--loops-that-matter.md index 98de73f1a..a2bbe189b 100644 --- a/docs/design/ltm--loops-that-matter.md +++ b/docs/design/ltm--loops-that-matter.md @@ -788,8 +788,8 @@ surfaces handle it differently: from the engine primitive (bound as `Sim.get_loops_runtime` -> `reclassify_loops_from_results`, GH #679/#685, the all-slots Rust source of truth) and attaches the per-step relative-score series on top. There is no - separate Python slot-0 reclassification path. (`LoopPolarity.from_runtime_scores` - survives only as a standalone scalar-array convenience utility.) + Python-side reclassification: the classification rules live in exactly one + place, the Rust engine (`ltm/types.rs`). - **libsimlin / WASM / TS `simlin_analyze_get_loops`**: **intentionally structural-only**. The FFI takes only a `SimlinModel` (no simulation `Results` in hand), folds `MostlyReinforcing`/`MostlyBalancing` to diff --git a/docs/sdai-model.schema.json b/docs/sdai-model.schema.json index 74a72b116..db28559ef 100644 --- a/docs/sdai-model.schema.json +++ b/docs/sdai-model.schema.json @@ -36,6 +36,12 @@ "items": { "$ref": "#/$defs/View" } + }, + "loop_metadata": { + "type": "array", + "items": { + "$ref": "#/$defs/LoopMetadata" + } } }, "required": [ @@ -167,6 +173,14 @@ "type": "null" } ] + }, + "uid": { + "description": "Stable numeric identifier for this variable, used to track references\nfrom loop_metadata across file saves and reloads.", + "type": [ + "integer", + "null" + ], + "format": "int32" } }, "required": [ @@ -206,6 +220,14 @@ "type": "null" } ] + }, + "uid": { + "description": "Stable numeric identifier for this variable, used to track references\nfrom loop_metadata across file saves and reloads.", + "type": [ + "integer", + "null" + ], + "format": "int32" } }, "required": [ @@ -245,6 +267,14 @@ "type": "null" } ] + }, + "uid": { + "description": "Stable numeric identifier for this variable, used to track references\nfrom loop_metadata across file saves and reloads.", + "type": [ + "integer", + "null" + ], + "format": "int32" } }, "required": [ @@ -351,13 +381,19 @@ "kind": { "type": "string" }, + "name": { + "type": [ + "string", + "null" + ] + }, "elements": { "type": "array", "items": { "$ref": "#/$defs/ViewElement" } }, - "view_box": { + "viewBox": { "anyOf": [ { "$ref": "#/$defs/Rect" @@ -370,6 +406,9 @@ "zoom": { "type": "number", "format": "double" + }, + "useLetteredPolarity": { + "type": "boolean" } }, "required": [ @@ -468,6 +507,19 @@ "required": [ "type" ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "group" + } + }, + "$ref": "#/$defs/GroupViewElement", + "required": [ + "type" + ] } ] }, @@ -489,7 +541,7 @@ "type": "number", "format": "double" }, - "label_side": { + "labelSide": { "type": "string" } }, @@ -511,7 +563,7 @@ "type": "number", "format": "double" }, - "attached_to_uid": { + "attachedToUid": { "type": "integer", "format": "int32" } @@ -539,7 +591,7 @@ "type": "number", "format": "double" }, - "label_side": { + "labelSide": { "type": "string" }, "points": { @@ -575,7 +627,7 @@ "type": "number", "format": "double" }, - "label_side": { + "labelSide": { "type": "string" } }, @@ -593,7 +645,7 @@ "type": "integer", "format": "int32" }, - "flow_uid": { + "flowUid": { "type": "integer", "format": "int32" }, @@ -608,7 +660,7 @@ }, "required": [ "uid", - "flow_uid", + "flowUid", "x", "y" ] @@ -637,11 +689,11 @@ "type": "integer", "format": "int32" }, - "from_uid": { + "fromUid": { "type": "integer", "format": "int32" }, - "to_uid": { + "toUid": { "type": "integer", "format": "int32" }, @@ -652,17 +704,23 @@ ], "format": "double" }, - "multi_points": { + "multiPoints": { "type": "array", "items": { "$ref": "#/$defs/LinkPoint" } + }, + "polarity": { + "type": [ + "string", + "null" + ] } }, "required": [ "uid", - "from_uid", - "to_uid" + "fromUid", + "toUid" ] }, "ModuleViewElement": { @@ -683,7 +741,7 @@ "type": "number", "format": "double" }, - "label_side": { + "labelSide": { "type": "string" } }, @@ -701,7 +759,7 @@ "type": "integer", "format": "int32" }, - "alias_of_uid": { + "aliasOfUid": { "type": "integer", "format": "int32" }, @@ -713,17 +771,57 @@ "type": "number", "format": "double" }, - "label_side": { + "labelSide": { "type": "string" } }, "required": [ "uid", - "alias_of_uid", + "aliasOfUid", "x", "y" ] }, + "GroupViewElement": { + "description": "Visual container for grouping related model elements.\nIn JSON (matching XMILE spec), x/y are top-left coordinates.", + "type": "object", + "properties": { + "uid": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "x": { + "type": "number", + "format": "double" + }, + "y": { + "type": "number", + "format": "double" + }, + "width": { + "type": "number", + "format": "double" + }, + "height": { + "type": "number", + "format": "double" + }, + "isMdlViewMarker": { + "type": "boolean" + } + }, + "required": [ + "uid", + "name", + "x", + "y", + "width", + "height" + ] + }, "Rect": { "type": "object", "properties": { @@ -750,6 +848,31 @@ "width", "height" ] + }, + "LoopMetadata": { + "type": "object", + "properties": { + "uids": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "deleted": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "uids", + "name" + ] } } } \ No newline at end of file diff --git a/docs/tech-debt.md b/docs/tech-debt.md index 7077e1de4..8e3de8a56 100644 --- a/docs/tech-debt.md +++ b/docs/tech-debt.md @@ -97,11 +97,11 @@ Known debt items consolidated from CLAUDE.md files and codebase analysis. Each e - **Component**: simlin-engine - **Severity**: low -- **Description**: 49 `#[allow(dead_code)]` attributes across 24 files. Heaviest in bytecode.rs (8), expr3.rs (5), dimensions.rs (4), compiler/context.rs (3), test_common.rs (3). Remaining suppressions fall into three categories: (1) ByteCodeContext builder methods unused in production because ByteCodeCompiler builds tables directly, (2) expr3 variants and methods reserved for pass 2, (3) scaffolding types (DimensionRange, DimensionVec, StridedDimension) for future strided array views. The stale Opcode-level suppression and reachable dimensions.rs code were cleaned up in the close-array-gaps work. +- **Description**: `#[allow(dead_code)]` attributes scattered through simlin-engine. The count is dominated by the sixteen `SymbolicOpcode` variants codegen never constructs (superseded incremental view-stack/broadcast opcodes whose retirement -- together with their `Opcode` twins, VM arms, and wasm arms -- is sequenced as its own change; see symbolic.rs), plus expr3 variants and methods reserved for pass 2. - **Measure**: `rg '#\[allow\(dead_code\)\]' --type rust src/simlin-engine/src/ -c` -- **Count**: 49 occurrences across 24 files (as of 2026-03-12) +- **Count**: 69 occurrences across 28 files (as of 2026-08-08) - **Owner**: unassigned -- **Last reviewed**: 2026-03-12 +- **Last reviewed**: 2026-08-08 ### 13. Ignored Rust Tests @@ -137,11 +137,11 @@ Known debt items consolidated from CLAUDE.md files and codebase analysis. Each e - **Component**: simlin-engine, libsimlin - **Severity**: low -- **Description**: 44 `eprintln!` calls in simlin-engine and 6 in libsimlin. In simlin-engine, 26 are in debug-gated functions (`debug_print_runlists` in interpreter.rs, `debug_print_bytecode` in vm.rs). The remaining 18 are runtime warnings in results.rs (unsupported sim methods), model.rs (compilation errors), and variable.rs. These should use proper error types or conditional logging rather than printing to stderr. +- **Description**: `eprintln!` calls in simlin-engine library code -- runtime warnings in results.rs (unsupported sim methods), model.rs (compilation errors), and variable.rs. These should use proper error types or conditional logging rather than printing to stderr. libsimlin has none. - **Measure**: `rg 'eprintln!' --type rust src/simlin-engine/src/ src/libsimlin/src/ -c` -- **Count**: 44 in simlin-engine, 6 in libsimlin (as of 2026-02-15) +- **Count**: 18 in simlin-engine, 0 in libsimlin (as of 2026-08-08) - **Owner**: unassigned -- **Last reviewed**: 2026-02-15 +- **Last reviewed**: 2026-08-08 ### 17. Embedded Error Fields on Variable/ModelStage Types diff --git a/src/app/Login.tsx b/src/app/Login.tsx index a4e4a2f56..8c1444fed 100644 --- a/src/app/Login.tsx +++ b/src/app/Login.tsx @@ -94,7 +94,7 @@ function firebaseErrorCode(err: unknown): string | undefined { return undefined; } -export const GoogleIcon: React.FunctionComponent = (props) => { +const GoogleIcon: React.FunctionComponent = (props) => { return ( diff --git a/src/app/tests/app.test.tsx b/src/app/tests/app.test.tsx index 222fb64b2..7627bd923 100644 --- a/src/app/tests/app.test.tsx +++ b/src/app/tests/app.test.tsx @@ -109,9 +109,6 @@ rs.mock('../NewUser', () => { import { describe, it, test, expect, beforeEach, afterEach, rs } from '@rstest/core'; import type { Mock, MockInstance } from '@rstest/core'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - import * as React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Router } from 'wouter'; @@ -289,51 +286,11 @@ describe('App routing (Switch first-match semantics)', () => { setFetchRoutes({}); }); - // Find the body of the JSX ... in App.tsx, stripping - // line comments so accidental references in `// ` text don't match. - function readSwitchBody(): string { - const sourceRaw = fs.readFileSync(path.join(__dirname, '..', 'App.tsx'), 'utf8'); - // Drop // line comments to avoid false matches on prose like "// ". - const source = sourceRaw - .split('\n') - .map((line) => { - const idx = line.indexOf('//'); - return idx === -1 ? line : line.substring(0, idx); - }) - .join('\n'); - const switchOpen = source.indexOf(''); - const switchClose = source.indexOf('', switchOpen); - expect(switchOpen).toBeGreaterThan(-1); - expect(switchClose).toBeGreaterThan(switchOpen); - return source.substring(switchOpen, switchClose); - } - - test('Switch directly contains Route children, not a wrapping div', () => { - // Structural assertion: wouter's uses flattenChildren which only - // descends into Fragments, not divs. A
child has no truthy `path` - // prop so wouter treats it as a wildcard match: cloneElement(
) is - // returned and Switch's first-match semantics are silently disabled. - // See node_modules/wouter/src/index.js (flattenChildren / Switch / Route). - // - // The fix is to either remove the div from inside Switch, or wrap routes - // in a Fragment. Either way, Switch's body must not contain a
. - expect(readSwitchBody()).not.toMatch(/ { - // Once Switch first-match semantics are restored, the dynamic two-segment - // route :username/:projectName would shadow any /new variant (well -- it - // wouldn't here because /new has only one segment, but more importantly: - // any future overlapping literal would silently double-render). Order - // routes literal-first as a defensive habit. - const switchBody = readSwitchBody(); - const newIdx = switchBody.indexOf('path="/new"'); - const dynIdx = switchBody.indexOf('path="/:username/:projectName"'); - expect(newIdx).toBeGreaterThan(-1); - expect(dynIdx).toBeGreaterThan(-1); - expect(newIdx).toBeLessThan(dynIdx); - }); - + // wouter's uses flattenChildren, which descends into Fragments but + // not divs: a
child has no truthy `path` prop, so wouter treats it as + // a wildcard match and first-match semantics are silently disabled. That is + // observable -- the route below renders the editor and nothing else -- so it + // is asserted through rendering rather than by matching App.tsx's source. test('renders the editor at /:user/:project (and not Home)', async () => { setLocation('/alice/widgets'); const { hook } = memoryLocation({ path: '/alice/widgets', static: true }); diff --git a/src/app/tests/design-tokens.test.ts b/src/app/tests/design-tokens.test.ts deleted file mode 100644 index 91de1dbcd..000000000 --- a/src/app/tests/design-tokens.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -// Design-token consolidation contracts for the app shell (issue #799). Asserts -// stylesheet text directly: the dense-toolbar spacer and the caption variant -// must derive from the shared theme.css tokens rather than restating literals, -// so the spacer can't drift from the dense Toolbar height and caption text -// scales with the rem type scale. - -import { describe, it, expect } from '@rstest/core'; - -import * as fs from 'fs'; -import * as path from 'path'; - -function readCss(name: string): string { - return fs.readFileSync(path.join(__dirname, '..', name), 'utf-8'); -} - -describe('app shell design-token coupling', () => { - it('the toolbar spacer height comes from --toolbar-dense-height', () => { - const css = readCss('Home.module.css'); - const m = /\.toolbarSpacer\s*\{([^}]*)\}/.exec(css); - expect(m).not.toBeNull(); - expect(m![1]).toContain('height: var(--toolbar-dense-height)'); - }); - - it('the caption typography variant sizes from --font-size-caption', () => { - const css = readCss('typography.module.css'); - const m = /\.caption\s*\{([^}]*)\}/.exec(css); - expect(m).not.toBeNull(); - expect(m![1]).toContain('font-size: var(--font-size-caption)'); - }); -}); diff --git a/src/app/tests/home.test.tsx b/src/app/tests/home.test.tsx index 566b370cf..21846ccb2 100644 --- a/src/app/tests/home.test.tsx +++ b/src/app/tests/home.test.tsx @@ -126,19 +126,6 @@ describe('Home.getProjects lifecycle', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('fetches after mount', async () => { - rs.useFakeTimers(); - const fetchMock = mockFetch(okProjects); - render( {}} />); - - expect(fetchMock).not.toHaveBeenCalled(); - await act(async () => { - rs.runAllTimers(); - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - it('cancels the deferred fetch when unmounted before it fires', () => { rs.useFakeTimers(); const fetchMock = mockFetch(okProjects); diff --git a/src/app/tests/login.test.tsx b/src/app/tests/login.test.tsx index 7bd10fa04..c4c901e0e 100644 --- a/src/app/tests/login.test.tsx +++ b/src/app/tests/login.test.tsx @@ -99,7 +99,7 @@ import { render, fireEvent, screen, waitFor } from '@testing-library/react'; import * as firebaseAuthModule from '@firebase/auth'; -import { Login, GoogleIcon } from '../Login'; +import { Login } from '../Login'; // rs.mock replaced the module above, so importing it normally yields the mock. // (jest.requireMock had no async equivalent -- rs.importMock returns a promise.) @@ -725,11 +725,6 @@ describe('Login miscellaneous error paths', () => { expect(screen.queryByText(/something unknown went wrong/i)).not.toBeNull(); }); }); - - test('GoogleIcon renders (it is dropped by the Button startIcon mock in flow tests)', () => { - const { container } = render(); - expect(container.querySelector('[data-component="SvgIcon"]')).not.toBeNull(); - }); }); describe('Login rendering guards', () => { diff --git a/src/diagram/rstest.config.mts b/src/diagram/rstest.config.mts index fa9439a87..880db5cfd 100644 --- a/src/diagram/rstest.config.mts +++ b/src/diagram/rstest.config.mts @@ -16,7 +16,6 @@ const coreSrc = path.resolve(here, '../core'); // a jsdom global would silently let a DOM dependency creep back in unnoticed. const NODE_ENV_TESTS = [ 'tests/editor-applyPatch.test.ts', - 'tests/editor-input.test.ts', 'tests/hosted-web-editor-delete.test.ts', 'tests/hosted-web-editor-load-errors.test.ts', 'tests/hosted-web-editor-save.test.ts', diff --git a/src/diagram/tests/appbar-toolbar.test.tsx b/src/diagram/tests/appbar-toolbar.test.tsx index 275f7e0e2..be7595fa3 100644 --- a/src/diagram/tests/appbar-toolbar.test.tsx +++ b/src/diagram/tests/appbar-toolbar.test.tsx @@ -20,30 +20,6 @@ describe('AppBar', () => { const header = screen.getByText('Content').closest('header'); expect(header).not.toBeNull(); }); - - test('applies static position by default', () => { - const { container } = render(Content); - const header = container.querySelector('header')!; - expect(header.className).toContain('positionStatic'); - }); - - test('applies fixed position class', () => { - const { container } = render(Content); - const header = container.querySelector('header')!; - expect(header.className).toContain('positionFixed'); - }); - - test('applies sticky position class', () => { - const { container } = render(Content); - const header = container.querySelector('header')!; - expect(header.className).toContain('positionSticky'); - }); - - test('applies custom className', () => { - const { container } = render(Content); - const header = container.querySelector('header')!; - expect(header.className).toContain('custom-bar'); - }); }); describe('Toolbar', () => { @@ -51,22 +27,4 @@ describe('Toolbar', () => { render(Toolbar Content); expect(screen.getByText('Toolbar Content')).not.toBeNull(); }); - - test('applies regular variant by default', () => { - const { container } = render(Content); - const toolbar = container.firstChild as HTMLElement; - expect(toolbar.className).toContain('regular'); - }); - - test('applies dense variant', () => { - const { container } = render(Content); - const toolbar = container.firstChild as HTMLElement; - expect(toolbar.className).toContain('dense'); - }); - - test('applies custom className', () => { - const { container } = render(Content); - const toolbar = container.firstChild as HTMLElement; - expect(toolbar.className).toContain('custom-toolbar'); - }); }); diff --git a/src/diagram/tests/arc-utils.test.ts b/src/diagram/tests/arc-utils.test.ts deleted file mode 100644 index 5fe53e2ea..000000000 --- a/src/diagram/tests/arc-utils.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { updateArcAngle, radToDeg, degToRad } from '../arc-utils'; - -describe('arc-utils', () => { - describe('updateArcAngle', () => { - describe('when arc is undefined (straight line)', () => { - it('should preserve undefined when angle difference is zero', () => { - expect(updateArcAngle(undefined, 0)).toBeUndefined(); - }); - - it('should preserve undefined when angle difference is positive', () => { - expect(updateArcAngle(undefined, 45)).toBeUndefined(); - }); - - it('should preserve undefined when angle difference is negative', () => { - expect(updateArcAngle(undefined, -30)).toBeUndefined(); - }); - - it('should preserve undefined for large angle differences', () => { - expect(updateArcAngle(undefined, 180)).toBeUndefined(); - expect(updateArcAngle(undefined, -180)).toBeUndefined(); - expect(updateArcAngle(undefined, 360)).toBeUndefined(); - }); - }); - - describe('when arc is defined (curved line)', () => { - it('should subtract the angle difference from the arc', () => { - expect(updateArcAngle(180, 0)).toBe(180); - expect(updateArcAngle(180, 10)).toBe(170); - expect(updateArcAngle(180, -10)).toBe(190); - }); - - it('should handle zero arc value', () => { - expect(updateArcAngle(0, 0)).toBe(0); - expect(updateArcAngle(0, 45)).toBe(-45); - expect(updateArcAngle(0, -45)).toBe(45); - }); - - it('should handle negative arc values', () => { - expect(updateArcAngle(-90, 10)).toBe(-100); - expect(updateArcAngle(-90, -10)).toBe(-80); - }); - - it('should handle fractional angle differences', () => { - expect(updateArcAngle(180, 0.5)).toBeCloseTo(179.5); - expect(updateArcAngle(180, -0.5)).toBeCloseTo(180.5); - }); - - it('should handle very small angle differences', () => { - expect(updateArcAngle(180, 0.001)).toBeCloseTo(179.999); - }); - }); - }); - - describe('radToDeg', () => { - it('should convert common radian values to degrees', () => { - expect(radToDeg(0)).toBe(0); - expect(radToDeg(Math.PI)).toBeCloseTo(180); - expect(radToDeg(Math.PI / 2)).toBeCloseTo(90); - expect(radToDeg(Math.PI / 4)).toBeCloseTo(45); - expect(radToDeg(2 * Math.PI)).toBeCloseTo(360); - }); - - it('should handle negative radian values', () => { - expect(radToDeg(-Math.PI)).toBeCloseTo(-180); - expect(radToDeg(-Math.PI / 2)).toBeCloseTo(-90); - }); - - it('should handle small radian values', () => { - expect(radToDeg(0.01)).toBeCloseTo(0.5729577951308232); - }); - }); - - describe('degToRad', () => { - it('should convert common degree values to radians', () => { - expect(degToRad(0)).toBe(0); - expect(degToRad(180)).toBeCloseTo(Math.PI); - expect(degToRad(90)).toBeCloseTo(Math.PI / 2); - expect(degToRad(45)).toBeCloseTo(Math.PI / 4); - expect(degToRad(360)).toBeCloseTo(2 * Math.PI); - }); - - it('should handle negative degree values', () => { - expect(degToRad(-180)).toBeCloseTo(-Math.PI); - expect(degToRad(-90)).toBeCloseTo(-Math.PI / 2); - }); - - it('should be inverse of radToDeg', () => { - for (const deg of [0, 30, 45, 90, 135, 180, 270, 360]) { - expect(radToDeg(degToRad(deg))).toBeCloseTo(deg); - } - }); - }); -}); diff --git a/src/diagram/tests/avatar.test.tsx b/src/diagram/tests/avatar.test.tsx index 9d1ec29be..bacb53d29 100644 --- a/src/diagram/tests/avatar.test.tsx +++ b/src/diagram/tests/avatar.test.tsx @@ -31,12 +31,6 @@ describe('Avatar', () => { expect(screen.queryByText('AB')).toBeNull(); }); - test('applies custom className', () => { - const { container } = render(AB); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('custom-avatar'); - }); - test('applies custom style', () => { const { container } = render(AB); const div = container.firstChild as HTMLElement; diff --git a/src/diagram/tests/button.test.tsx b/src/diagram/tests/button.test.tsx index 2622773cf..142cf6d57 100644 --- a/src/diagram/tests/button.test.tsx +++ b/src/diagram/tests/button.test.tsx @@ -45,121 +45,9 @@ describe('Button', () => { expect(onClick).not.toHaveBeenCalled(); }); - test('applies contained primary classes', () => { - render( - , - ); - const button = screen.getByRole('button'); - expect(button.className).toContain('containedPrimary'); - }); - - test('applies contained secondary classes', () => { - render( - , - ); - const button = screen.getByRole('button'); - expect(button.className).toContain('containedSecondary'); - }); - - test('applies text variant by default', () => { - render(); - const button = screen.getByRole('button'); - expect(button.className).toContain('textPrimary'); - }); - - test('applies contained error classes', () => { - render( - , - ); - expect(screen.getByRole('button').className).toContain('containedError'); - }); - - test('applies outlined error classes', () => { - render( - , - ); - expect(screen.getByRole('button').className).toContain('outlinedError'); - }); - - test('applies text error classes', () => { - render(); - expect(screen.getByRole('button').className).toContain('textError'); - }); - - test('applies outlined primary classes', () => { - render( - , - ); - const button = screen.getByRole('button'); - expect(button.className).toContain('outlinedPrimary'); - }); - - test('applies outlined inherit classes', () => { - render( - , - ); - const button = screen.getByRole('button'); - expect(button.className).toContain('outlinedInherit'); - }); - - test('applies size classes', () => { - const { rerender } = render(); - expect(screen.getByRole('button').className).toContain('sizeSmall'); - - rerender(); - expect(screen.getByRole('button').className).toContain('sizeLarge'); - }); - - test('applies medium size by default', () => { - render(); - expect(screen.getByRole('button').className).toContain('sizeMedium'); - }); - test('renders startIcon', () => { render(); expect(screen.getByTestId('icon')).not.toBeNull(); - const iconWrapper = screen.getByTestId('icon').parentElement; - expect(iconWrapper!.className).toContain('startIcon'); - }); - - test('applies disabled class for text variant', () => { - render(); - expect(screen.getByRole('button').className).toContain('disabledText'); - }); - - test('applies disabled class for contained variant', () => { - render( - , - ); - expect(screen.getByRole('button').className).toContain('disabledContained'); - }); - - test('applies disabled class for outlined variant', () => { - render( - , - ); - expect(screen.getByRole('button').className).toContain('disabledOutlined'); - }); - - test('applies custom className', () => { - render(); - expect(screen.getByRole('button').className).toContain('custom'); }); test('passes through aria attributes', () => { diff --git a/src/diagram/tests/canvas-interaction.test.ts b/src/diagram/tests/canvas-interaction.test.ts index 0e5faf9a0..26732b9cd 100644 --- a/src/diagram/tests/canvas-interaction.test.ts +++ b/src/diagram/tests/canvas-interaction.test.ts @@ -26,7 +26,6 @@ import { idleState, InteractionContext, InteractionEffect, - InteractionEvent, InteractionState, isDrag, isInDragSelectRect, @@ -326,27 +325,3 @@ describe('reduceInteraction: label drag start (shell-driven)', () => { // lives in Canvas.handleSetSelection (composing decideMouseDownSelection / // resolveSelectionForReattachment and building the union variant directly); the // reconciler-level gesture suite (canvas-gestures-elements.test.tsx) covers it. - -describe('idleState', () => { - it('is the idle mode', () => { - const s: InteractionState = idleState; - expect(s.mode).toBe('idle'); - }); -}); - -// Type-only event coverage: exercising the discriminant ensures the union stays -// exhaustive for the shell's translation layer. (No elementPointerDown: element -// press resolution lives in the shell, not the reducer.) -describe('InteractionEvent kinds', () => { - it('enumerates the supported kinds', () => { - const kinds: InteractionEvent['kind'][] = [ - 'canvasPointerDown', - 'createToolPointerDown', - 'flowToolPointerDown', - 'pinchStart', - 'pinchEnd', - 'labelDragStart', - ]; - expect(new Set(kinds).size).toBe(6); - }); -}); diff --git a/src/diagram/tests/card.test.tsx b/src/diagram/tests/card.test.tsx index e93e655d3..c60975ab8 100644 --- a/src/diagram/tests/card.test.tsx +++ b/src/diagram/tests/card.test.tsx @@ -14,25 +14,6 @@ describe('Card', () => { expect(screen.getByText('Card content')).not.toBeNull(); }); - test('applies elevation variant by default', () => { - const { container } = render(Content); - const card = container.firstChild as HTMLElement; - expect(card.className).toContain('elevation'); - }); - - test('applies outlined variant', () => { - const { container } = render(Content); - const card = container.firstChild as HTMLElement; - expect(card.className).toContain('outlined'); - expect(card.className).not.toContain('elevation'); - }); - - test('applies custom className', () => { - const { container } = render(Content); - const card = container.firstChild as HTMLElement; - expect(card.className).toContain('custom'); - }); - test('applies custom style', () => { const { container } = render(Content); const card = container.firstChild as HTMLElement; @@ -45,18 +26,6 @@ describe('CardContent', () => { render(Inner content); expect(screen.getByText('Inner content')).not.toBeNull(); }); - - test('applies cardContent class', () => { - const { container } = render(Content); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('cardContent'); - }); - - test('applies custom className', () => { - const { container } = render(Content); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('custom'); - }); }); describe('CardActions', () => { @@ -68,24 +37,4 @@ describe('CardActions', () => { ); expect(screen.getByText('Action')).not.toBeNull(); }); - - test('applies cardActions class', () => { - const { container } = render( - - - , - ); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('cardActions'); - }); - - test('applies custom className', () => { - const { container } = render( - - - , - ); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('custom'); - }); }); diff --git a/src/diagram/tests/chart-utils.test.ts b/src/diagram/tests/chart-utils.test.ts index 75a55d7fe..d514e8d67 100644 --- a/src/diagram/tests/chart-utils.test.ts +++ b/src/diagram/tests/chart-utils.test.ts @@ -87,11 +87,6 @@ describe('niceAxisTicks', () => { test('generates nice round numbers', () => { const ticks = niceAxisTicks(0, 1); - for (const t of ticks) { - // all ticks should be finite numbers - expect(Number.isFinite(t)).toBe(true); - } - // should include 0 and 1 expect(ticks).toContain(0); expect(ticks).toContain(1); }); @@ -192,13 +187,6 @@ describe('findNearestPointIndex', () => { expect(findNearestPointIndex([], 5)).toBe(-1); }); - test('handles midpoint between two points', () => { - const points = [{ x: 0 }, { x: 10 }]; - // at exact midpoint, either 0 or 1 is acceptable - const idx = findNearestPointIndex(points, 5); - expect(idx === 0 || idx === 1).toBe(true); - }); - test('works with floating point values', () => { const points = [{ x: 0.1 }, { x: 0.2 }, { x: 0.3 }, { x: 0.4 }]; expect(findNearestPointIndex(points, 0.25)).toBe(1); // closer to 0.2 diff --git a/src/diagram/tests/checkbox.test.tsx b/src/diagram/tests/checkbox.test.tsx index 47c79a21c..9c5752228 100644 --- a/src/diagram/tests/checkbox.test.tsx +++ b/src/diagram/tests/checkbox.test.tsx @@ -29,18 +29,6 @@ describe('Checkbox', () => { expect(onChange).toHaveBeenCalledWith(false); }); - test('applies primary class by default', () => { - render(); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox.className).toContain('primary'); - }); - - test('applies secondary class', () => { - render(); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox.className).toContain('secondary'); - }); - test('respects disabled prop', () => { const onChange = rs.fn(); render(); @@ -48,12 +36,6 @@ describe('Checkbox', () => { expect(checkbox).toHaveProperty('disabled', true); }); - test('applies custom className', () => { - render(); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox.className).toContain('custom-check'); - }); - test('renders with checked state', () => { render(); const checkbox = screen.getByRole('checkbox'); diff --git a/src/diagram/tests/colors.test.ts b/src/diagram/tests/colors.test.ts deleted file mode 100644 index 2a302ec3a..000000000 --- a/src/diagram/tests/colors.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -// Test our in-tree Dark2 palette implementation -import { describe, it, expect } from '@rstest/core'; - -import { Dark2 } from '../colors'; - -describe('Dark2 palette', () => { - it('has exactly 8 colors', () => { - expect(Dark2).toHaveLength(8); - }); - - it('contains valid hex color codes', () => { - const hexPattern = /^#[0-9a-f]{6}$/i; - for (const color of Dark2) { - expect(color).toMatch(hexPattern); - } - }); - - it('contains the expected ColorBrewer Dark2 colors', () => { - // These values were verified against chroma-js brewer.Dark2 - expect(Dark2[0]).toBe('#1b9e77'); // teal - expect(Dark2[1]).toBe('#d95f02'); // orange - expect(Dark2[2]).toBe('#7570b3'); // purple - expect(Dark2[3]).toBe('#e7298a'); // pink - expect(Dark2[4]).toBe('#66a61e'); // green - expect(Dark2[5]).toBe('#e6ab02'); // yellow - expect(Dark2[6]).toBe('#a6761d'); // brown - expect(Dark2[7]).toBe('#666666'); // gray - }); -}); diff --git a/src/diagram/tests/css-module-stub.test.ts b/src/diagram/tests/css-module-stub.test.ts deleted file mode 100644 index 79d761a97..000000000 --- a/src/diagram/tests/css-module-stub.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; -describe('CSS module stub for Node.js', () => { - it('should return empty string for any property access', () => { - // This tests the behavior of the CSS module stubs generated by build:css-stubs - // The stubs use: module.exports = new Proxy({}, { get: () => '' }); - const stub: Record = new Proxy({}, { get: () => '' }); - - expect(stub.canvas).toBe(''); - expect(stub.anyPropertyName).toBe(''); - expect(stub.simlinCanvas).toBe(''); - expect(stub.nonExistentClass).toBe(''); - }); - - it('should work correctly in template literals', () => { - const stub: Record = new Proxy({}, { get: () => '' }); - - // When used in template literals, empty string should not produce "undefined" - const className = `${stub.canvas} simlin-canvas`; - expect(className).toBe(' simlin-canvas'); - expect(className).not.toContain('undefined'); - }); - - it('should work correctly with string concatenation', () => { - const stub: Record = new Proxy({}, { get: () => '' }); - - const className = stub.module + ' simlin-module'; - expect(className).toBe(' simlin-module'); - expect(className).not.toContain('undefined'); - }); - - it('should be safe to use with clsx-style operations', () => { - const stub: Record = new Proxy({}, { get: () => '' }); - - // clsx filters falsy values, empty string is falsy - const classes = [stub.canvas, 'simlin-canvas'].filter(Boolean); - expect(classes).toEqual(['simlin-canvas']); - }); -}); diff --git a/src/diagram/tests/dialog.test.tsx b/src/diagram/tests/dialog.test.tsx index 3df87c282..faa31ba59 100644 --- a/src/diagram/tests/dialog.test.tsx +++ b/src/diagram/tests/dialog.test.tsx @@ -121,12 +121,6 @@ describe('DialogContent', () => { render(Content area); expect(screen.getByText('Content area')).not.toBeNull(); }); - - test('applies dialogContent class', () => { - const { container } = render(Content); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('dialogContent'); - }); }); describe('DialogContentText', () => { @@ -135,12 +129,6 @@ describe('DialogContentText', () => { const p = screen.getByText('Some text'); expect(p.tagName).toBe('P'); }); - - test('applies contentText class', () => { - render(Text); - const p = screen.getByText('Text'); - expect(p.className).toContain('contentText'); - }); }); describe('DialogActions', () => { @@ -152,14 +140,4 @@ describe('DialogActions', () => { ); expect(screen.getByText('OK')).not.toBeNull(); }); - - test('applies actions class', () => { - const { container } = render( - - - , - ); - const div = container.firstChild as HTMLElement; - expect(div.className).toContain('actions'); - }); }); diff --git a/src/diagram/tests/drawer.test.tsx b/src/diagram/tests/drawer.test.tsx index 64e1fc363..19ce3336d 100644 --- a/src/diagram/tests/drawer.test.tsx +++ b/src/diagram/tests/drawer.test.tsx @@ -274,92 +274,6 @@ describe('Drawer focus trap', () => { expect(document.activeElement).toBe(secondBtn); }); - test('focus trap includes button elements', () => { - render( - {}}> - - , - ); - - const panel = document.querySelector('[role="dialog"]'); - const focusable = panel!.querySelectorAll( - 'a, button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable]', - ); - expect(focusable.length).toBe(1); - }); - - test('focus trap includes anchor elements', () => { - render( - {}}> - - Link - - Anchor without href - , - ); - - const panel = document.querySelector('[role="dialog"]'); - const focusable = panel!.querySelectorAll( - 'a, button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable]', - ); - // Both 'a' tags are matched by the 'a' selector - expect(focusable.length).toBe(2); - }); - - test('focus trap includes input elements', () => { - render( - {}}> - - -