Skip to content

Commit b6a0fc8

Browse files
authored
Merge pull request #26 from konard/issue-25-a5fff5d90bf5
test(core): add comprehensive unit and integration tests for issue #25
2 parents 3665695 + ceb0aef commit b6a0fc8

7 files changed

Lines changed: 828 additions & 134 deletions
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { types as t } from "@babel/core"
2+
import { describe, expect, it } from "@effect/vitest"
3+
import { Effect } from "effect"
4+
5+
import { attrExists, createPathAttribute, type JsxTaggerContext, processJsxElement } from "../../src/core/jsx-tagger.js"
6+
import {
7+
createEmptyNodeWithLocation,
8+
createNodeWithClassName,
9+
createNodeWithClassNameAndLocation
10+
} from "./jsx-test-fixtures.js"
11+
12+
// CHANGE: add comprehensive unit tests for jsx-tagger core functions
13+
// WHY: ensure mathematical invariants and idempotency properties are verified
14+
// QUOTE(ТЗ): "Unit: formatComponentPathValue, attrExists, processJsxElement (идемпотентность, пропуск без loc)"
15+
// REF: issue-25
16+
// FORMAT THEOREM: ∀ test ∈ Tests: test verifies declared invariant
17+
// PURITY: tests verify CORE purity and SHELL effects
18+
// INVARIANT: tests catch regressions in attribute handling and format
19+
// COMPLEXITY: O(1) per test case
20+
21+
// CHANGE: extract context factory to module scope per linter requirement
22+
// WHY: unicorn/consistent-function-scoping rule enforces scope consistency
23+
// REF: ESLint unicorn plugin rules
24+
const createTestContext = (filename = "src/App.tsx", attributeName = "data-path"): JsxTaggerContext => ({
25+
relativeFilename: filename,
26+
attributeName
27+
})
28+
29+
describe("jsx-tagger", () => {
30+
describe("attrExists", () => {
31+
// FORMAT THEOREM: ∀ node, name: attrExists(node, name) ↔ ∃ attr ∈ node.attributes: attr.name = name
32+
// INVARIANT: predicate returns true iff attribute with exact name exists
33+
// COMPLEXITY: O(n) where n = number of attributes
34+
35+
it.effect("returns false when element has no attributes", () =>
36+
Effect.sync(() => {
37+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [])
38+
expect(attrExists(node, "data-path", t)).toBe(false)
39+
}))
40+
41+
it.effect("returns false when attribute does not exist", () =>
42+
Effect.sync(() => {
43+
const node = createNodeWithClassName(t)
44+
expect(attrExists(node, "data-path", t)).toBe(false)
45+
}))
46+
47+
it.effect("returns true when attribute exists", () =>
48+
Effect.sync(() => {
49+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
50+
t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("src/App.tsx:10:5"))
51+
])
52+
expect(attrExists(node, "data-path", t)).toBe(true)
53+
}))
54+
55+
it.effect("returns true when attribute exists among multiple attributes", () =>
56+
Effect.sync(() => {
57+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
58+
t.jsxAttribute(t.jsxIdentifier("className"), t.stringLiteral("container")),
59+
t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("src/App.tsx:10:5")),
60+
t.jsxAttribute(t.jsxIdentifier("id"), t.stringLiteral("main"))
61+
])
62+
expect(attrExists(node, "data-path", t)).toBe(true)
63+
}))
64+
65+
it.effect("returns false for spread attributes", () =>
66+
Effect.sync(() => {
67+
const spreadAttr = t.jsxSpreadAttribute(t.identifier("props"))
68+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [spreadAttr])
69+
expect(attrExists(node, "data-path", t)).toBe(false)
70+
}))
71+
72+
it.effect("distinguishes between different attribute names", () =>
73+
Effect.sync(() => {
74+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
75+
t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("value"))
76+
])
77+
expect(attrExists(node, "path", t)).toBe(false)
78+
expect(attrExists(node, "data-path", t)).toBe(true)
79+
}))
80+
})
81+
82+
describe("createPathAttribute", () => {
83+
// FORMAT THEOREM: ∀ f, l, c: createPathAttribute(f, l, c) = JSXAttribute(path, f:l:c)
84+
// INVARIANT: output format is always path:line:column
85+
// COMPLEXITY: O(1)/O(1)
86+
87+
it.effect("creates JSX attribute with correct format", () =>
88+
Effect.sync(() => {
89+
const attr = createPathAttribute("data-path", "src/App.tsx", 10, 5, t)
90+
91+
expect(t.isJSXAttribute(attr)).toBe(true)
92+
expect(t.isJSXIdentifier(attr.name)).toBe(true)
93+
expect(attr.name.name).toBe("data-path")
94+
expect(t.isStringLiteral(attr.value)).toBe(true)
95+
if (t.isStringLiteral(attr.value)) {
96+
expect(attr.value.value).toBe("src/App.tsx:10:5")
97+
}
98+
}))
99+
100+
it.effect("handles nested directory paths", () =>
101+
Effect.sync(() => {
102+
const attr = createPathAttribute("data-path", "src/components/ui/Button.tsx", 42, 8, t)
103+
104+
if (t.isStringLiteral(attr.value)) {
105+
expect(attr.value.value).toBe("src/components/ui/Button.tsx:42:8")
106+
}
107+
}))
108+
109+
it.effect("handles line 1 and column 0", () =>
110+
Effect.sync(() => {
111+
const attr = createPathAttribute("data-path", "index.tsx", 1, 0, t)
112+
113+
if (t.isStringLiteral(attr.value)) {
114+
expect(attr.value.value).toBe("index.tsx:1:0")
115+
}
116+
}))
117+
118+
it.effect("handles large line and column numbers", () =>
119+
Effect.sync(() => {
120+
const attr = createPathAttribute("data-path", "src/LargeFile.tsx", 9999, 999, t)
121+
122+
if (t.isStringLiteral(attr.value)) {
123+
expect(attr.value.value).toBe("src/LargeFile.tsx:9999:999")
124+
}
125+
}))
126+
})
127+
128+
describe("processJsxElement", () => {
129+
// FORMAT THEOREM: ∀ jsx ∈ JSXOpeningElement: processElement(jsx) → tagged(jsx) ∨ skipped(jsx)
130+
// INVARIANT: idempotent - processing same element twice produces same result
131+
// INVARIANT: each JSX element has at most one path attribute after processing
132+
// COMPLEXITY: O(n)/O(1) where n = number of existing attributes
133+
134+
it.effect("adds path attribute when element has no attributes", () =>
135+
Effect.sync(() => {
136+
const node = createEmptyNodeWithLocation(t)
137+
const result = processJsxElement(node, createTestContext(), t)
138+
139+
expect(result).toBe(true)
140+
expect(node.attributes.length).toBe(1)
141+
expect(attrExists(node, "data-path", t)).toBe(true)
142+
143+
const pathAttr = node.attributes[0]
144+
if (t.isJSXAttribute(pathAttr) && t.isStringLiteral(pathAttr.value)) {
145+
expect(pathAttr.value.value).toBe("src/App.tsx:10:5")
146+
}
147+
}))
148+
149+
it.effect("adds path attribute when element has other attributes", () =>
150+
Effect.sync(() => {
151+
const node = createNodeWithClassNameAndLocation(t)
152+
const result = processJsxElement(node, createTestContext(), t)
153+
154+
expect(result).toBe(true)
155+
expect(node.attributes.length).toBe(2)
156+
expect(attrExists(node, "data-path", t)).toBe(true)
157+
}))
158+
159+
it.effect("skips element without location info (loc === null)", () =>
160+
Effect.sync(() => {
161+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [])
162+
node.loc = null
163+
164+
const result = processJsxElement(node, createTestContext(), t)
165+
166+
expect(result).toBe(false)
167+
expect(node.attributes.length).toBe(0)
168+
expect(attrExists(node, "data-path", t)).toBe(false)
169+
}))
170+
171+
it.effect("skips element that already has path attribute (idempotency)", () =>
172+
Effect.sync(() => {
173+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
174+
t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("src/Old.tsx:5:0"))
175+
])
176+
node.loc = {
177+
start: { line: 20, column: 3, index: 0 },
178+
end: { line: 20, column: 15, index: 0 },
179+
filename: "src/App.tsx",
180+
identifierName: undefined
181+
}
182+
183+
const result = processJsxElement(node, createTestContext(), t)
184+
185+
expect(result).toBe(false)
186+
expect(node.attributes.length).toBe(1) // No new attribute added
187+
const pathAttr = node.attributes[0]
188+
if (t.isJSXAttribute(pathAttr) && t.isStringLiteral(pathAttr.value)) {
189+
expect(pathAttr.value.value).toBe("src/Old.tsx:5:0") // Original value preserved
190+
}
191+
}))
192+
193+
it.effect("is truly idempotent - processing twice produces same result", () =>
194+
Effect.sync(() => {
195+
const node = createEmptyNodeWithLocation(t)
196+
197+
// First processing
198+
const result1 = processJsxElement(node, createTestContext(), t)
199+
expect(result1).toBe(true)
200+
const attributesAfterFirst = node.attributes.length
201+
202+
// Second processing (should be no-op)
203+
const result2 = processJsxElement(node, createTestContext(), t)
204+
expect(result2).toBe(false)
205+
expect(node.attributes.length).toBe(attributesAfterFirst)
206+
}))
207+
208+
it.effect("uses context filename for path value", () =>
209+
Effect.sync(() => {
210+
const node = t.jsxOpeningElement(t.jsxIdentifier("button"), [])
211+
node.loc = {
212+
start: { line: 7, column: 12, index: 0 },
213+
end: { line: 7, column: 20, index: 0 },
214+
filename: "different.tsx",
215+
identifierName: undefined
216+
}
217+
218+
const context = createTestContext("src/components/Button.tsx")
219+
processJsxElement(node, context, t)
220+
221+
const pathAttr = node.attributes.find(
222+
(attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "data-path" })
223+
)
224+
expect(pathAttr).toBeDefined()
225+
if (pathAttr && t.isJSXAttribute(pathAttr) && t.isStringLiteral(pathAttr.value)) {
226+
expect(pathAttr.value.value).toBe("src/components/Button.tsx:7:12")
227+
}
228+
}))
229+
230+
it.effect("preserves existing attributes when adding path", () =>
231+
Effect.sync(() => {
232+
const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
233+
t.jsxAttribute(t.jsxIdentifier("className"), t.stringLiteral("container")),
234+
t.jsxAttribute(t.jsxIdentifier("id"), t.stringLiteral("main")),
235+
t.jsxSpreadAttribute(t.identifier("props"))
236+
])
237+
node.loc = {
238+
start: { line: 25, column: 0, index: 0 },
239+
end: { line: 25, column: 30, index: 0 },
240+
filename: "src/App.tsx",
241+
identifierName: undefined
242+
}
243+
244+
processJsxElement(node, createTestContext(), t)
245+
246+
expect(node.attributes.length).toBe(4)
247+
// Verify original attributes still exist
248+
expect(attrExists(node, "className", t)).toBe(true)
249+
expect(attrExists(node, "id", t)).toBe(true)
250+
expect(attrExists(node, "data-path", t)).toBe(true)
251+
}))
252+
})
253+
})
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { types as t } from "@babel/core"
2+
3+
// CHANGE: extract common test fixtures to reduce code duplication
4+
// WHY: vibecode-linter detects duplicates in test setup code
5+
// REF: issue-25 test implementation
6+
// PURITY: CORE (pure test data factories)
7+
// INVARIANT: factories produce deterministic test nodes
8+
// COMPLEXITY: O(1) per factory call
9+
10+
/**
11+
* Creates a mock SourceLocation for testing.
12+
*
13+
* @pure true
14+
* @complexity O(1)
15+
*/
16+
export const createMockLocation = (
17+
line = 10,
18+
column = 5
19+
): t.SourceLocation => ({
20+
start: { line, column, index: 0 },
21+
end: { line, column: column + 5, index: 0 },
22+
filename: "src/App.tsx",
23+
identifierName: undefined
24+
})
25+
26+
/**
27+
* Creates a JSX opening element with className attribute for testing.
28+
*
29+
* @pure true
30+
* @complexity O(1)
31+
*/
32+
export const createNodeWithClassName = (
33+
types: typeof t,
34+
className = "container"
35+
): t.JSXOpeningElement => {
36+
const node = types.jsxOpeningElement(types.jsxIdentifier("div"), [
37+
types.jsxAttribute(types.jsxIdentifier("className"), types.stringLiteral(className))
38+
])
39+
return node
40+
}
41+
42+
/**
43+
* Creates an empty JSX opening element for testing.
44+
*
45+
* @pure true
46+
* @complexity O(1)
47+
*/
48+
export const createEmptyNode = (types: typeof t): t.JSXOpeningElement =>
49+
types.jsxOpeningElement(types.jsxIdentifier("div"), [])
50+
51+
/**
52+
* Creates an empty JSX opening element with location info for testing.
53+
* Combines node creation and location setup to reduce duplication.
54+
*
55+
* @pure true
56+
* @complexity O(1)
57+
*/
58+
export const createEmptyNodeWithLocation = (
59+
types: typeof t,
60+
line = 10,
61+
column = 5
62+
): t.JSXOpeningElement => {
63+
const node = createEmptyNode(types)
64+
node.loc = createMockLocation(line, column)
65+
return node
66+
}
67+
68+
/**
69+
* Creates a JSX opening element with className attribute and location info for testing.
70+
* Combines node creation and location setup to reduce duplication.
71+
*
72+
* @pure true
73+
* @complexity O(1)
74+
*/
75+
export const createNodeWithClassNameAndLocation = (
76+
types: typeof t,
77+
className = "container",
78+
line = 15,
79+
column = 2
80+
): t.JSXOpeningElement => {
81+
const node = createNodeWithClassName(types, className)
82+
node.loc = createMockLocation(line, column)
83+
return node
84+
}

0 commit comments

Comments
 (0)