Skip to content

Commit 2e116bf

Browse files
authored
Merge pull request #1 from hate/feat/comprehensive-test-suite
Add comprehensive test suite
2 parents f9459f6 + dea081f commit 2e116bf

18 files changed

Lines changed: 1578 additions & 92 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,8 @@ jobs:
4949
- name: Build frontend
5050
run: pnpm build:web
5151

52+
- name: Run frontend tests
53+
run: pnpm --filter @devkeys/web test
54+
5255
- name: Run Rust tests
5356
run: cargo test --workspace --all-targets

apps/web/.eslintrc.cjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ module.exports = {
2222
rules: {
2323
"react/react-in-jsx-scope": "off",
2424
},
25+
overrides: [
26+
{
27+
files: ["**/*.test.ts", "**/*.test.tsx"],
28+
rules: {
29+
"@typescript-eslint/no-explicit-any": "off",
30+
},
31+
},
32+
{
33+
files: ["**/*.d.ts"],
34+
rules: {
35+
"@typescript-eslint/no-explicit-any": "off",
36+
},
37+
},
38+
],
2539
settings: {
2640
react: {
2741
version: "detect",
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import { TypingFeedback } from "./TypingFeedback";
4+
import type { CharacterFeedback } from "../../../shared/feedback/feedback";
5+
import type { SyntaxToken } from "../../../shared/syntax/syntax";
6+
import type { SyntaxThemeId } from "../../../shared/syntax/themes";
7+
8+
// Mock the styles module
9+
vi.mock("./TypingFeedback.styles", () => ({
10+
typingFeedbackStyles: {
11+
wrapper: "typing-feedback-wrapper",
12+
gutter: "typing-feedback-gutter",
13+
content: "typing-feedback-content",
14+
pre: "typing-feedback-pre",
15+
pendingSpan: (syntaxClass: string) => `pending-span ${syntaxClass}`,
16+
typedSpan: (syntaxClass: string, decorationClass: string) => `typed-span ${syntaxClass} ${decorationClass}`,
17+
cursor: "typing-feedback-cursor",
18+
typedOverlay: "typed-overlay",
19+
},
20+
}));
21+
22+
// Mock the feedback module
23+
vi.mock("../../../shared/feedback/feedback", () => ({
24+
feedbackClass: (state: string) => {
25+
const classes = {
26+
pending: "text-slate-500",
27+
"first-hit": "",
28+
"second-hit": "",
29+
"multi-hit": "ring-1 ring-amber-400/40",
30+
error: "bg-rose-600/20 underline decoration-rose-400 decoration-2",
31+
};
32+
return classes[state as keyof typeof classes] || "";
33+
},
34+
}));
35+
36+
// Mock the syntax module
37+
vi.mock("../../../shared/syntax/syntax", () => ({
38+
getSyntaxColorClass: (tokenType: string, isPending: boolean, themeId: string) =>
39+
`syntax-${tokenType}-${isPending ? "pending" : "typed"}-${themeId}`,
40+
getTokenTypeAtIndex: (tokens: SyntaxToken[], index: number) => {
41+
const token = tokens.find(t => t.startIndex <= index && index < t.endIndex);
42+
return token?.type || "text";
43+
},
44+
}));
45+
46+
// Mock the text utils
47+
vi.mock("../utils/text", () => ({
48+
normalizeWhitespace: (text: string) => text.replace(/\s/g, "·"),
49+
}));
50+
51+
// Mock framer-motion
52+
vi.mock("framer-motion", () => ({
53+
motion: {
54+
span: ({ children, className, ...props }: React.HTMLAttributes<HTMLSpanElement> & { children: React.ReactNode }) => (
55+
<span className={className} {...props}>
56+
{children}
57+
</span>
58+
),
59+
},
60+
}));
61+
62+
describe("TypingFeedback", () => {
63+
const createFeedback = (overrides: Partial<CharacterFeedback> = {}): CharacterFeedback => ({
64+
index: 0,
65+
char: "a",
66+
typed: undefined,
67+
state: "pending",
68+
...overrides,
69+
});
70+
71+
const createSyntaxToken = (start: number, end: number, type: string): SyntaxToken => ({
72+
startIndex: start,
73+
endIndex: end,
74+
type,
75+
});
76+
77+
const defaultProps = {
78+
feedback: [createFeedback()],
79+
typedLength: 0,
80+
hasStarted: false,
81+
syntaxTokens: [createSyntaxToken(0, 1, "keyword")],
82+
fontSize: 14,
83+
syntaxThemeId: "vscode-dark" as SyntaxThemeId,
84+
};
85+
86+
it("renders with basic props", () => {
87+
render(<TypingFeedback {...defaultProps} />);
88+
89+
expect(screen.getByText("1")).toBeInTheDocument(); // Line number
90+
expect(screen.getByText("1").closest("div")).toBeInTheDocument(); // Component wrapper
91+
});
92+
93+
it("shows correct line count for multi-line content", () => {
94+
const feedback = [
95+
createFeedback({ char: "a", index: 0 }),
96+
createFeedback({ char: "\n", index: 1 }),
97+
createFeedback({ char: "b", index: 2 }),
98+
];
99+
100+
render(<TypingFeedback {...defaultProps} feedback={feedback} />);
101+
102+
expect(screen.getByText("1")).toBeInTheDocument();
103+
expect(screen.getByText("2")).toBeInTheDocument();
104+
});
105+
106+
it("applies correct styles based on feedback state", () => {
107+
const feedback = [
108+
createFeedback({ char: "a", index: 0, state: "pending" }),
109+
createFeedback({ char: "b", index: 1, state: "first-hit" }),
110+
createFeedback({ char: "c", index: 2, state: "error", typed: "x" }),
111+
];
112+
113+
render(<TypingFeedback {...defaultProps} feedback={feedback} />);
114+
115+
// Check that component renders without errors
116+
expect(screen.getByText("1")).toBeInTheDocument();
117+
});
118+
119+
it("shows cursor on active character", () => {
120+
const feedback = [
121+
createFeedback({ char: "a", index: 0, state: "pending" }),
122+
createFeedback({ char: "b", index: 1, state: "pending" }),
123+
];
124+
125+
render(<TypingFeedback {...defaultProps} feedback={feedback} typedLength={1} />);
126+
127+
// Check that component renders without errors
128+
expect(screen.getByText("1")).toBeInTheDocument();
129+
});
130+
131+
it("shows typed overlay for errors", () => {
132+
const feedback = [
133+
createFeedback({
134+
char: "a",
135+
index: 0,
136+
state: "error",
137+
typed: "x"
138+
}),
139+
];
140+
141+
render(<TypingFeedback {...defaultProps} feedback={feedback} />);
142+
143+
expect(screen.getByText("x")).toBeInTheDocument();
144+
expect(screen.getByText("x")).toHaveClass("typed-overlay");
145+
});
146+
147+
it("applies syntax highlighting classes", () => {
148+
const feedback = [createFeedback({ char: "a", index: 0 })];
149+
const syntaxTokens = [createSyntaxToken(0, 1, "keyword")];
150+
151+
render(<TypingFeedback {...defaultProps} feedback={feedback} syntaxTokens={syntaxTokens} />);
152+
153+
// Check that component renders without errors
154+
expect(screen.getByText("1")).toBeInTheDocument();
155+
});
156+
157+
it("shows gutter with correct opacity based on hasStarted", () => {
158+
const { rerender } = render(<TypingFeedback {...defaultProps} hasStarted={false} />);
159+
160+
const gutter = screen.getByText("1").parentElement;
161+
expect(gutter).toHaveStyle("opacity: 0");
162+
163+
rerender(<TypingFeedback {...defaultProps} hasStarted={true} />);
164+
expect(gutter).toHaveStyle("opacity: 1");
165+
});
166+
167+
it("applies correct font size", () => {
168+
render(<TypingFeedback {...defaultProps} fontSize={18} />);
169+
170+
// Check that component renders without errors
171+
expect(screen.getByText("1")).toBeInTheDocument();
172+
});
173+
174+
it("handles empty feedback array", () => {
175+
render(<TypingFeedback {...defaultProps} feedback={[]} />);
176+
177+
expect(screen.getByText("1")).toBeInTheDocument(); // Should still show line 1
178+
});
179+
180+
it("shows title attribute for error states with typed characters", () => {
181+
const feedback = [
182+
createFeedback({
183+
char: "a",
184+
index: 0,
185+
state: "error",
186+
typed: "x"
187+
}),
188+
];
189+
190+
render(<TypingFeedback {...defaultProps} feedback={feedback} />);
191+
192+
const span = screen.getByTitle("Typed: x");
193+
expect(span).toBeInTheDocument();
194+
});
195+
196+
it("handles different syntax theme IDs", () => {
197+
const feedback = [createFeedback({ char: "a", index: 0 })];
198+
199+
render(<TypingFeedback {...defaultProps} feedback={feedback} syntaxThemeId="dracula" />);
200+
201+
// Check that component renders without errors
202+
expect(screen.getByText("1")).toBeInTheDocument();
203+
});
204+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2+
import { computeWhitespaceSegment, syncCaretPosition } from "./tabAdvance";
3+
4+
describe("tabAdvance utilities", () => {
5+
describe("computeWhitespaceSegment", () => {
6+
it("returns empty string for out of bounds index", () => {
7+
expect(computeWhitespaceSegment("hello", 10)).toBe("");
8+
expect(computeWhitespaceSegment("", 0)).toBe("");
9+
});
10+
11+
it("returns empty string when no whitespace at start", () => {
12+
expect(computeWhitespaceSegment("hello", 0)).toBe("");
13+
expect(computeWhitespaceSegment("world", 2)).toBe("");
14+
});
15+
16+
it("finds single space", () => {
17+
expect(computeWhitespaceSegment(" hello", 0)).toBe(" ");
18+
expect(computeWhitespaceSegment("a b", 1)).toBe(" ");
19+
});
20+
21+
it("finds multiple spaces", () => {
22+
expect(computeWhitespaceSegment(" hello", 0)).toBe(" ");
23+
expect(computeWhitespaceSegment("a b", 1)).toBe(" ");
24+
});
25+
26+
it("finds single tab", () => {
27+
expect(computeWhitespaceSegment("\thello", 0)).toBe("\t");
28+
expect(computeWhitespaceSegment("a\tb", 1)).toBe("\t");
29+
});
30+
31+
it("finds multiple tabs", () => {
32+
expect(computeWhitespaceSegment("\t\thello", 0)).toBe("\t\t");
33+
expect(computeWhitespaceSegment("a\t\tb", 1)).toBe("\t\t");
34+
});
35+
36+
it("finds mixed spaces and tabs", () => {
37+
expect(computeWhitespaceSegment(" \t hello", 0)).toBe(" \t ");
38+
expect(computeWhitespaceSegment("a \t b", 1)).toBe(" \t ");
39+
});
40+
41+
it("stops at non-whitespace characters", () => {
42+
expect(computeWhitespaceSegment(" hello world", 0)).toBe(" ");
43+
expect(computeWhitespaceSegment("a hello b", 1)).toBe(" ");
44+
});
45+
46+
it("handles edge cases", () => {
47+
expect(computeWhitespaceSegment(" ", 0)).toBe(" ");
48+
expect(computeWhitespaceSegment("\t", 0)).toBe("\t");
49+
expect(computeWhitespaceSegment(" ", 0)).toBe(" ");
50+
});
51+
});
52+
53+
describe("syncCaretPosition", () => {
54+
let mockRef: { current: Pick<HTMLTextAreaElement, "setSelectionRange"> | null };
55+
let mockSetSelectionRange: ReturnType<typeof vi.fn>;
56+
let mockRequestAnimationFrame: ReturnType<typeof vi.fn>;
57+
58+
beforeEach(() => {
59+
mockSetSelectionRange = vi.fn();
60+
mockRequestAnimationFrame = vi.fn((callback) => {
61+
callback();
62+
return 1;
63+
});
64+
65+
mockRef = {
66+
current: {
67+
setSelectionRange: mockSetSelectionRange,
68+
},
69+
};
70+
71+
// Mock requestAnimationFrame
72+
Object.defineProperty(window, "requestAnimationFrame", {
73+
writable: true,
74+
value: mockRequestAnimationFrame,
75+
});
76+
});
77+
78+
afterEach(() => {
79+
vi.restoreAllMocks();
80+
});
81+
82+
it("calls setSelectionRange with correct position", () => {
83+
syncCaretPosition(mockRef as any, 5);
84+
85+
expect(mockRequestAnimationFrame).toHaveBeenCalledTimes(1);
86+
expect(mockSetSelectionRange).toHaveBeenCalledWith(5, 5);
87+
});
88+
89+
it("handles null ref gracefully", () => {
90+
const nullRef: { current: null } = { current: null };
91+
92+
expect(() => {
93+
syncCaretPosition(nullRef as any, 5);
94+
}).not.toThrow();
95+
96+
expect(mockSetSelectionRange).not.toHaveBeenCalled();
97+
});
98+
99+
it("handles missing requestAnimationFrame", () => {
100+
// Remove requestAnimationFrame
101+
Object.defineProperty(window, "requestAnimationFrame", {
102+
writable: true,
103+
value: undefined,
104+
});
105+
106+
expect(() => {
107+
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 3);
108+
}).not.toThrow();
109+
110+
expect(mockSetSelectionRange).toHaveBeenCalledWith(3, 3);
111+
});
112+
113+
it("calls setSelectionRange with different positions", () => {
114+
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 0);
115+
expect(mockSetSelectionRange).toHaveBeenCalledWith(0, 0);
116+
117+
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 10);
118+
expect(mockSetSelectionRange).toHaveBeenCalledWith(10, 10);
119+
120+
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 100);
121+
expect(mockSetSelectionRange).toHaveBeenCalledWith(100, 100);
122+
});
123+
124+
it("handles negative positions", () => {
125+
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, -1);
126+
expect(mockSetSelectionRange).toHaveBeenCalledWith(-1, -1);
127+
});
128+
});
129+
});

0 commit comments

Comments
 (0)