sceneview-mcp 3.0.2 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sceneview-mcp",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "MCP server for SceneView — 3D and AR with Jetpack Compose for Android. Give Claude the full SceneView SDK so it writes correct, compilable Kotlin.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -31,7 +31,11 @@
31
31
  "sceneview-mcp": "dist/index.js"
32
32
  },
33
33
  "files": [
34
- "dist",
34
+ "dist/index.js",
35
+ "dist/issues.js",
36
+ "dist/migration.js",
37
+ "dist/samples.js",
38
+ "dist/validator.js",
35
39
  "llms.txt"
36
40
  ],
37
41
  "engines": {
@@ -1,114 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- // We test the private formatIssues logic by calling fetchKnownIssues with a
3
- // mocked global fetch so we never hit the real GitHub API in tests.
4
- const mockIssues = [
5
- {
6
- number: 123,
7
- title: "SIGABRT on dispose",
8
- html_url: "https://github.com/SceneView/sceneview-android/issues/123",
9
- body: "Calling destroy() causes a native crash.",
10
- labels: [{ name: "bug" }],
11
- created_at: "2026-01-01T00:00:00Z",
12
- updated_at: "2026-01-15T00:00:00Z",
13
- user: { login: "testuser" },
14
- },
15
- {
16
- number: 124,
17
- title: "How do I add shadows?",
18
- html_url: "https://github.com/SceneView/sceneview-android/issues/124",
19
- body: "I want to enable shadows for my AR scene.",
20
- labels: [{ name: "question" }],
21
- created_at: "2026-01-02T00:00:00Z",
22
- updated_at: "2026-01-16T00:00:00Z",
23
- user: { login: "anotheruser" },
24
- },
25
- ];
26
- beforeEach(() => {
27
- // Reset module cache so the in-memory cache is cleared between tests
28
- vi.resetModules();
29
- });
30
- describe("fetchKnownIssues", () => {
31
- it("returns markdown with issue titles when GitHub API succeeds", async () => {
32
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
33
- ok: true,
34
- json: async () => mockIssues,
35
- }));
36
- const { fetchKnownIssues } = await import("./issues.js");
37
- const result = await fetchKnownIssues();
38
- expect(result).toContain("SIGABRT on dispose");
39
- expect(result).toContain("#123");
40
- expect(result).toContain("How do I add shadows?");
41
- expect(result).toContain("#124");
42
- vi.unstubAllGlobals();
43
- });
44
- it("groups bug-labelled issues under Bug Reports", async () => {
45
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
46
- ok: true,
47
- json: async () => mockIssues,
48
- }));
49
- const { fetchKnownIssues } = await import("./issues.js");
50
- const result = await fetchKnownIssues();
51
- expect(result).toContain("Bug Reports");
52
- expect(result).toContain("SIGABRT on dispose");
53
- vi.unstubAllGlobals();
54
- });
55
- it("groups non-bug issues under Other Issues", async () => {
56
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
57
- ok: true,
58
- json: async () => mockIssues,
59
- }));
60
- const { fetchKnownIssues } = await import("./issues.js");
61
- const result = await fetchKnownIssues();
62
- expect(result).toContain("Other Issues");
63
- expect(result).toContain("How do I add shadows?");
64
- vi.unstubAllGlobals();
65
- });
66
- it("includes github URLs", async () => {
67
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
68
- ok: true,
69
- json: async () => mockIssues,
70
- }));
71
- const { fetchKnownIssues } = await import("./issues.js");
72
- const result = await fetchKnownIssues();
73
- expect(result).toContain("https://github.com/SceneView/sceneview-android/issues/123");
74
- vi.unstubAllGlobals();
75
- });
76
- it("includes body excerpt", async () => {
77
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
78
- ok: true,
79
- json: async () => mockIssues,
80
- }));
81
- const { fetchKnownIssues } = await import("./issues.js");
82
- const result = await fetchKnownIssues();
83
- expect(result).toContain("native crash");
84
- vi.unstubAllGlobals();
85
- });
86
- it("shows warning message when GitHub API fails", async () => {
87
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
88
- ok: false,
89
- status: 403,
90
- statusText: "Forbidden",
91
- }));
92
- const { fetchKnownIssues } = await import("./issues.js");
93
- const result = await fetchKnownIssues();
94
- expect(result).toContain("403");
95
- vi.unstubAllGlobals();
96
- });
97
- it("shows warning message when fetch throws (network error)", async () => {
98
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")));
99
- const { fetchKnownIssues } = await import("./issues.js");
100
- const result = await fetchKnownIssues();
101
- expect(result).toContain("Network error");
102
- vi.unstubAllGlobals();
103
- });
104
- it("shows celebration when there are no open issues", async () => {
105
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
106
- ok: true,
107
- json: async () => [],
108
- }));
109
- const { fetchKnownIssues } = await import("./issues.js");
110
- const result = await fetchKnownIssues();
111
- expect(result).toContain("No open issues");
112
- vi.unstubAllGlobals();
113
- });
114
- });
@@ -1,50 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { MIGRATION_GUIDE } from "./migration.js";
3
- describe("MIGRATION_GUIDE", () => {
4
- it("is a non-empty string", () => {
5
- expect(typeof MIGRATION_GUIDE).toBe("string");
6
- expect(MIGRATION_GUIDE.length).toBeGreaterThan(500);
7
- });
8
- it("covers composable renames (SceneView → Scene, ArSceneView → ARScene)", () => {
9
- expect(MIGRATION_GUIDE).toContain("SceneView");
10
- expect(MIGRATION_GUIDE).toContain("Scene");
11
- expect(MIGRATION_GUIDE).toContain("ArSceneView");
12
- expect(MIGRATION_GUIDE).toContain("ARScene");
13
- });
14
- it("covers model loading migration (loadModelAsync → rememberModelInstance)", () => {
15
- expect(MIGRATION_GUIDE).toContain("loadModelAsync");
16
- expect(MIGRATION_GUIDE).toContain("rememberModelInstance");
17
- });
18
- it("covers removed nodes", () => {
19
- expect(MIGRATION_GUIDE).toContain("TransformableNode");
20
- expect(MIGRATION_GUIDE).toContain("PlacementNode");
21
- expect(MIGRATION_GUIDE).toContain("ViewRenderable");
22
- });
23
- it("covers LightNode named apply parameter gotcha", () => {
24
- expect(MIGRATION_GUIDE).toContain("apply");
25
- expect(MIGRATION_GUIDE).toContain("LightNode");
26
- expect(MIGRATION_GUIDE).toContain("trailing lambda");
27
- });
28
- it("covers engine lifecycle (rememberEngine)", () => {
29
- expect(MIGRATION_GUIDE).toContain("rememberEngine");
30
- expect(MIGRATION_GUIDE).toContain("engine.destroy");
31
- });
32
- it("covers AR anchor drift (worldPosition → AnchorNode)", () => {
33
- expect(MIGRATION_GUIDE).toContain("worldPosition");
34
- expect(MIGRATION_GUIDE).toContain("AnchorNode");
35
- expect(MIGRATION_GUIDE).toContain("drift");
36
- });
37
- it("covers gradle dependency changes", () => {
38
- expect(MIGRATION_GUIDE).toContain("io.github.sceneview:sceneview:3.0.0");
39
- expect(MIGRATION_GUIDE).toContain("io.github.sceneview:arsceneview:3.0.0");
40
- });
41
- it("includes a migration checklist", () => {
42
- expect(MIGRATION_GUIDE).toContain("Checklist");
43
- expect(MIGRATION_GUIDE).toContain("- [ ]");
44
- });
45
- it("includes before/after code examples", () => {
46
- expect(MIGRATION_GUIDE).toContain("Before");
47
- expect(MIGRATION_GUIDE).toContain("After");
48
- expect(MIGRATION_GUIDE).toContain("```kotlin");
49
- });
50
- });
@@ -1,119 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { SAMPLES, SAMPLE_IDS, getSample } from "./samples.js";
3
- describe("SAMPLE_IDS", () => {
4
- it("contains all 6 expected scenarios", () => {
5
- expect(SAMPLE_IDS).toContain("model-viewer");
6
- expect(SAMPLE_IDS).toContain("geometry-scene");
7
- expect(SAMPLE_IDS).toContain("ar-tap-to-place");
8
- expect(SAMPLE_IDS).toContain("ar-placement-cursor");
9
- expect(SAMPLE_IDS).toContain("ar-augmented-image");
10
- expect(SAMPLE_IDS).toContain("ar-face-filter");
11
- });
12
- it("SAMPLE_IDS matches keys of SAMPLES", () => {
13
- expect(SAMPLE_IDS.sort()).toEqual(Object.keys(SAMPLES).sort());
14
- });
15
- });
16
- describe("every sample", () => {
17
- for (const id of SAMPLE_IDS) {
18
- const sample = SAMPLES[id];
19
- it(`${id}: has all required fields`, () => {
20
- expect(sample.id).toBe(id);
21
- expect(sample.title).toBeTruthy();
22
- expect(sample.description).toBeTruthy();
23
- expect(sample.tags.length).toBeGreaterThan(0);
24
- expect(sample.dependency).toMatch(/^io\.github\.sceneview:/);
25
- expect(sample.prompt).toBeTruthy();
26
- expect(sample.code).toBeTruthy();
27
- });
28
- it(`${id}: code is non-empty Kotlin`, () => {
29
- expect(sample.code).toContain("@Composable");
30
- expect(sample.code).toContain("fun ");
31
- });
32
- it(`${id}: dependency is a valid sceneview artifact`, () => {
33
- expect(["io.github.sceneview:sceneview:3.0.0", "io.github.sceneview:arsceneview:3.0.0"]).toContain(sample.dependency);
34
- });
35
- }
36
- });
37
- describe("AR samples", () => {
38
- const arIds = SAMPLE_IDS.filter((id) => SAMPLES[id].tags.includes("ar"));
39
- it("all AR samples use arsceneview dependency", () => {
40
- for (const id of arIds) {
41
- expect(SAMPLES[id].dependency).toBe("io.github.sceneview:arsceneview:3.0.0");
42
- }
43
- });
44
- it("all AR samples contain ARScene in code", () => {
45
- for (const id of arIds) {
46
- expect(SAMPLES[id].code).toContain("ARScene");
47
- }
48
- });
49
- it("all AR samples have the 'ar' tag", () => {
50
- for (const id of arIds) {
51
- expect(SAMPLES[id].tags).toContain("ar");
52
- }
53
- });
54
- });
55
- describe("3D samples", () => {
56
- const d3Ids = SAMPLE_IDS.filter((id) => SAMPLES[id].tags.includes("3d"));
57
- it("all 3D samples use sceneview dependency", () => {
58
- for (const id of d3Ids) {
59
- expect(SAMPLES[id].dependency).toBe("io.github.sceneview:sceneview:3.0.0");
60
- }
61
- });
62
- it("all 3D samples contain Scene in code", () => {
63
- for (const id of d3Ids) {
64
- expect(SAMPLES[id].code).toContain("Scene(");
65
- }
66
- });
67
- });
68
- describe("getSample", () => {
69
- it("returns the correct sample by ID", () => {
70
- const s = getSample("model-viewer");
71
- expect(s).toBeDefined();
72
- expect(s.id).toBe("model-viewer");
73
- expect(s.title).toBe("3D Model Viewer");
74
- });
75
- it("returns undefined for an unknown ID", () => {
76
- expect(getSample("nonexistent-scenario")).toBeUndefined();
77
- });
78
- it("returns all samples without undefined", () => {
79
- for (const id of SAMPLE_IDS) {
80
- expect(getSample(id)).toBeDefined();
81
- }
82
- });
83
- });
84
- describe("tag filtering (simulating list_samples tool)", () => {
85
- const filterByTag = (tag) => Object.values(SAMPLES).filter((s) => s.tags.includes(tag));
86
- it("tag 'ar' returns only AR samples", () => {
87
- const results = filterByTag("ar");
88
- expect(results.length).toBeGreaterThan(0);
89
- results.forEach((s) => expect(s.tags).toContain("ar"));
90
- });
91
- it("tag '3d' returns only 3D samples", () => {
92
- const results = filterByTag("3d");
93
- expect(results.length).toBeGreaterThan(0);
94
- results.forEach((s) => expect(s.tags).toContain("3d"));
95
- });
96
- it("tag 'face-tracking' returns only the face filter sample", () => {
97
- const results = filterByTag("face-tracking");
98
- expect(results).toHaveLength(1);
99
- expect(results[0].id).toBe("ar-face-filter");
100
- });
101
- it("tag 'image-tracking' returns only augmented image sample", () => {
102
- const results = filterByTag("image-tracking");
103
- expect(results).toHaveLength(1);
104
- expect(results[0].id).toBe("ar-augmented-image");
105
- });
106
- it("tag 'geometry' returns only geometry scene", () => {
107
- const results = filterByTag("geometry");
108
- expect(results).toHaveLength(1);
109
- expect(results[0].id).toBe("geometry-scene");
110
- });
111
- it("tag 'anchor' returns AR samples that use anchors", () => {
112
- const results = filterByTag("anchor");
113
- expect(results.length).toBeGreaterThan(0);
114
- results.forEach((s) => expect(s.tags).toContain("anchor"));
115
- });
116
- it("unknown tag returns empty array", () => {
117
- expect(filterByTag("nonexistent-tag")).toHaveLength(0);
118
- });
119
- });
@@ -1,246 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { validateCode, formatValidationReport } from "./validator.js";
3
- // ─── Helpers ─────────────────────────────────────────────────────────────────
4
- function ruleIds(code) {
5
- return validateCode(code).map((i) => i.rule);
6
- }
7
- function hasRule(code, rule) {
8
- return ruleIds(code).includes(rule);
9
- }
10
- // ─── threading/filament-off-main-thread ──────────────────────────────────────
11
- describe("threading/filament-off-main-thread", () => {
12
- const RULE = "threading/filament-off-main-thread";
13
- it("fires when modelLoader.createModel* used near Dispatchers.IO", () => {
14
- const code = `
15
- withContext(Dispatchers.IO) {
16
- val model = modelLoader.createModelInstance("models/chair.glb")
17
- }
18
- `;
19
- expect(hasRule(code, RULE)).toBe(true);
20
- });
21
- it("fires when Texture.Builder used near Dispatchers.Default", () => {
22
- const code = `
23
- launch(Dispatchers.Default) {
24
- val texture = Texture.Builder().width(4).build(engine)
25
- }
26
- `;
27
- expect(hasRule(code, RULE)).toBe(true);
28
- });
29
- it("does NOT fire without a background dispatcher", () => {
30
- const code = `val model = modelLoader.createModelInstance("models/chair.glb")`;
31
- expect(hasRule(code, RULE)).toBe(false);
32
- });
33
- it("does NOT fire when Dispatchers.Main is used", () => {
34
- const code = `
35
- withContext(Dispatchers.Main) {
36
- val model = modelLoader.createModelInstance("models/chair.glb")
37
- }
38
- `;
39
- expect(hasRule(code, RULE)).toBe(false);
40
- });
41
- });
42
- // ─── ar/node-not-anchor ───────────────────────────────────────────────────────
43
- describe("ar/node-not-anchor", () => {
44
- const RULE = "ar/node-not-anchor";
45
- it("fires when worldPosition is set inside an ARScene", () => {
46
- const code = `
47
- ARScene(engine = engine) {
48
- node.worldPosition = Position(0f, 0f, -1f)
49
- }
50
- `;
51
- expect(hasRule(code, RULE)).toBe(true);
52
- });
53
- it("does NOT fire for worldPosition outside ARScene", () => {
54
- const code = `node.worldPosition = Position(0f, 0f, -1f)`;
55
- expect(hasRule(code, RULE)).toBe(false);
56
- });
57
- it("does NOT fire when AnchorNode is used correctly", () => {
58
- const code = `
59
- ARScene(engine = engine) {
60
- AnchorNode(anchor = hitResult.createAnchor()) { }
61
- }
62
- `;
63
- expect(hasRule(code, RULE)).toBe(false);
64
- });
65
- });
66
- // ─── composable/model-instance-null-check ────────────────────────────────────
67
- describe("composable/model-instance-null-check", () => {
68
- const RULE = "composable/model-instance-null-check";
69
- it("fires when rememberModelInstance result used without null guard", () => {
70
- const code = `
71
- val instance = rememberModelInstance(modelLoader, "models/chair.glb")
72
- ModelNode(modelInstance = instance, scaleToUnits = 1f)
73
- `;
74
- expect(hasRule(code, RULE)).toBe(true);
75
- });
76
- it("does NOT fire when guarded with ?.", () => {
77
- const code = `
78
- val instance = rememberModelInstance(modelLoader, "models/chair.glb")
79
- instance?.let { ModelNode(modelInstance = it) }
80
- `;
81
- expect(hasRule(code, RULE)).toBe(false);
82
- });
83
- it("does NOT fire when guarded with !!", () => {
84
- const code = `
85
- val instance = rememberModelInstance(modelLoader, "models/chair.glb")
86
- ModelNode(modelInstance = instance!!, scaleToUnits = 1f)
87
- `;
88
- expect(hasRule(code, RULE)).toBe(false);
89
- });
90
- it("does NOT fire when rememberModelInstance is not assigned to a var", () => {
91
- const code = `ModelNode(modelInstance = someOtherInstance, scaleToUnits = 1f)`;
92
- expect(hasRule(code, RULE)).toBe(false);
93
- });
94
- });
95
- // ─── api/light-node-trailing-lambda ──────────────────────────────────────────
96
- describe("api/light-node-trailing-lambda", () => {
97
- const RULE = "api/light-node-trailing-lambda";
98
- it("fires on trailing lambda syntax", () => {
99
- const code = `LightNode(engine = engine, type = LightManager.Type.SUN) { intensity(100_000f) }`;
100
- expect(hasRule(code, RULE)).toBe(true);
101
- });
102
- it("does NOT fire when apply = { } is used correctly", () => {
103
- const code = `LightNode(engine = engine, type = LightManager.Type.SUN, apply = { intensity(100_000f) })`;
104
- expect(hasRule(code, RULE)).toBe(false);
105
- });
106
- });
107
- // ─── lifecycle/manual-engine-create ──────────────────────────────────────────
108
- describe("lifecycle/manual-engine-create", () => {
109
- const RULE = "lifecycle/manual-engine-create";
110
- it("fires on Engine.create()", () => {
111
- const code = `val engine = Engine.create(eglContext)`;
112
- expect(hasRule(code, RULE)).toBe(true);
113
- });
114
- it("does NOT fire when rememberEngine() is used", () => {
115
- const code = `val engine = rememberEngine()`;
116
- expect(hasRule(code, RULE)).toBe(false);
117
- });
118
- });
119
- // ─── lifecycle/manual-engine-destroy ─────────────────────────────────────────
120
- describe("lifecycle/manual-engine-destroy", () => {
121
- const RULE = "lifecycle/manual-engine-destroy";
122
- it("fires when engine.destroy() called alongside rememberEngine", () => {
123
- const code = `
124
- val engine = rememberEngine()
125
- DisposableEffect(Unit) { onDispose { engine.destroy() } }
126
- `;
127
- expect(hasRule(code, RULE)).toBe(true);
128
- });
129
- it("does NOT fire when rememberEngine is absent (imperative code)", () => {
130
- const code = `engine.destroy()`;
131
- expect(hasRule(code, RULE)).toBe(false);
132
- });
133
- });
134
- // ─── lifecycle/texture-destroy-order ─────────────────────────────────────────
135
- describe("lifecycle/texture-destroy-order", () => {
136
- const RULE = "lifecycle/texture-destroy-order";
137
- it("fires when texture is destroyed before material instance", () => {
138
- const code = `
139
- engine.safeDestroyTexture(texture)
140
- materialLoader.destroyMaterialInstance(instance)
141
- `;
142
- expect(hasRule(code, RULE)).toBe(true);
143
- });
144
- it("does NOT fire when material instance destroyed first", () => {
145
- const code = `
146
- materialLoader.destroyMaterialInstance(instance)
147
- engine.safeDestroyTexture(texture)
148
- `;
149
- expect(hasRule(code, RULE)).toBe(false);
150
- });
151
- it("does NOT fire when only one of the two is present", () => {
152
- expect(hasRule(`engine.safeDestroyTexture(texture)`, RULE)).toBe(false);
153
- expect(hasRule(`materialLoader.destroyMaterialInstance(instance)`, RULE)).toBe(false);
154
- });
155
- });
156
- // ─── composable/prefer-remember-model-instance ────────────────────────────────
157
- describe("composable/prefer-remember-model-instance", () => {
158
- const RULE = "composable/prefer-remember-model-instance";
159
- it("fires when createModelInstance is used directly", () => {
160
- const code = `val m = modelLoader.createModelInstance("models/chair.glb")`;
161
- expect(hasRule(code, RULE)).toBe(true);
162
- });
163
- it("does NOT fire for rememberModelInstance", () => {
164
- const code = `val m = rememberModelInstance(modelLoader, "models/chair.glb")`;
165
- expect(hasRule(code, RULE)).toBe(false);
166
- });
167
- });
168
- // ─── ar/anchor-node-missing-anchor ───────────────────────────────────────────
169
- describe("ar/anchor-node-missing-anchor", () => {
170
- const RULE = "ar/anchor-node-missing-anchor";
171
- it("fires on empty AnchorNode()", () => {
172
- const code = `val node = AnchorNode()`;
173
- expect(hasRule(code, RULE)).toBe(true);
174
- });
175
- it("does NOT fire when anchor param is provided", () => {
176
- const code = `AnchorNode(anchor = hitResult.createAnchor())`;
177
- expect(hasRule(code, RULE)).toBe(false);
178
- });
179
- });
180
- // ─── migration/old-api ────────────────────────────────────────────────────────
181
- describe("migration/old-api", () => {
182
- const RULE = "migration/old-api";
183
- it("fires on SceneView composable", () => {
184
- expect(hasRule(`SceneView(modifier = Modifier.fillMaxSize())`, RULE)).toBe(true);
185
- });
186
- it("fires on ArSceneView composable", () => {
187
- expect(hasRule(`ArSceneView(modifier = Modifier.fillMaxSize())`, RULE)).toBe(true);
188
- });
189
- it("fires on TransformableNode", () => {
190
- expect(hasRule(`val node = TransformableNode(system)`, RULE)).toBe(true);
191
- });
192
- it("fires on PlacementNode", () => {
193
- expect(hasRule(`val node = PlacementNode()`, RULE)).toBe(true);
194
- });
195
- it("fires on ViewRenderable", () => {
196
- expect(hasRule(`ViewRenderable.builder()`, RULE)).toBe(true);
197
- });
198
- it("fires on loadModelAsync", () => {
199
- expect(hasRule(`modelLoader.loadModelAsync("models/x.glb")`, RULE)).toBe(true);
200
- });
201
- it("does NOT fire on modern 3.0 APIs", () => {
202
- const code = `
203
- val engine = rememberEngine()
204
- Scene(engine = engine) {
205
- rememberModelInstance(modelLoader, "models/x.glb")?.let { ModelNode(modelInstance = it) }
206
- }
207
- `;
208
- expect(hasRule(code, RULE)).toBe(false);
209
- });
210
- });
211
- // ─── api/scene-missing-engine ─────────────────────────────────────────────────
212
- describe("api/scene-missing-engine", () => {
213
- const RULE = "api/scene-missing-engine";
214
- it("fires when Scene() has no engine nearby", () => {
215
- const code = `Scene(modifier = Modifier.fillMaxSize()) { }`;
216
- expect(hasRule(code, RULE)).toBe(true);
217
- });
218
- it("does NOT fire when engine is provided", () => {
219
- const code = `
220
- val engine = rememberEngine()
221
- Scene(modifier = Modifier.fillMaxSize(), engine = engine) { }
222
- `;
223
- expect(hasRule(code, RULE)).toBe(false);
224
- });
225
- });
226
- // ─── formatValidationReport ───────────────────────────────────────────────────
227
- describe("formatValidationReport", () => {
228
- it("returns success message when no issues", () => {
229
- expect(formatValidationReport([])).toContain("No issues found");
230
- });
231
- it("includes severity counts in the header", () => {
232
- const issues = validateCode(`
233
- val instance = rememberModelInstance(modelLoader, "x.glb")
234
- ModelNode(modelInstance = instance, scaleToUnits = 1f)
235
- LightNode(engine = engine) { intensity(1f) }
236
- `);
237
- const report = formatValidationReport(issues);
238
- expect(report).toMatch(/error/);
239
- expect(report).toContain("🔴");
240
- });
241
- it("includes line numbers when available", () => {
242
- const code = `\nval instance = rememberModelInstance(modelLoader, "x.glb")\nModelNode(modelInstance = instance, scaleToUnits = 1f)`;
243
- const report = formatValidationReport(validateCode(code));
244
- expect(report).toMatch(/line \d+/);
245
- });
246
- });