Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,8 @@ jobs:
- name: Build frontend
run: pnpm build:web

- name: Run frontend tests
run: pnpm --filter @devkeys/web test

- name: Run Rust tests
run: cargo test --workspace --all-targets
14 changes: 14 additions & 0 deletions apps/web/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ module.exports = {
rules: {
"react/react-in-jsx-scope": "off",
},
overrides: [
{
files: ["**/*.test.ts", "**/*.test.tsx"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
{
files: ["**/*.d.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
],
settings: {
react: {
version: "detect",
Expand Down
204 changes: 204 additions & 0 deletions apps/web/src/features/editor/components/TypingFeedback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TypingFeedback } from "./TypingFeedback";
import type { CharacterFeedback } from "../../../shared/feedback/feedback";
import type { SyntaxToken } from "../../../shared/syntax/syntax";
import type { SyntaxThemeId } from "../../../shared/syntax/themes";

// Mock the styles module
vi.mock("./TypingFeedback.styles", () => ({
typingFeedbackStyles: {
wrapper: "typing-feedback-wrapper",
gutter: "typing-feedback-gutter",
content: "typing-feedback-content",
pre: "typing-feedback-pre",
pendingSpan: (syntaxClass: string) => `pending-span ${syntaxClass}`,
typedSpan: (syntaxClass: string, decorationClass: string) => `typed-span ${syntaxClass} ${decorationClass}`,
cursor: "typing-feedback-cursor",
typedOverlay: "typed-overlay",
},
}));

// Mock the feedback module
vi.mock("../../../shared/feedback/feedback", () => ({
feedbackClass: (state: string) => {
const classes = {
pending: "text-slate-500",
"first-hit": "",
"second-hit": "",
"multi-hit": "ring-1 ring-amber-400/40",
error: "bg-rose-600/20 underline decoration-rose-400 decoration-2",
};
return classes[state as keyof typeof classes] || "";
},
}));

// Mock the syntax module
vi.mock("../../../shared/syntax/syntax", () => ({
getSyntaxColorClass: (tokenType: string, isPending: boolean, themeId: string) =>
`syntax-${tokenType}-${isPending ? "pending" : "typed"}-${themeId}`,
getTokenTypeAtIndex: (tokens: SyntaxToken[], index: number) => {
const token = tokens.find(t => t.startIndex <= index && index < t.endIndex);
return token?.type || "text";
},
}));

// Mock the text utils
vi.mock("../utils/text", () => ({
normalizeWhitespace: (text: string) => text.replace(/\s/g, "·"),
}));

// Mock framer-motion
vi.mock("framer-motion", () => ({
motion: {
span: ({ children, className, ...props }: React.HTMLAttributes<HTMLSpanElement> & { children: React.ReactNode }) => (
<span className={className} {...props}>
{children}
</span>
),
},
}));

describe("TypingFeedback", () => {
const createFeedback = (overrides: Partial<CharacterFeedback> = {}): CharacterFeedback => ({
index: 0,
char: "a",
typed: undefined,
state: "pending",
...overrides,
});

const createSyntaxToken = (start: number, end: number, type: string): SyntaxToken => ({
startIndex: start,
endIndex: end,
type,
});

const defaultProps = {
feedback: [createFeedback()],
typedLength: 0,
hasStarted: false,
syntaxTokens: [createSyntaxToken(0, 1, "keyword")],
fontSize: 14,
syntaxThemeId: "vscode-dark" as SyntaxThemeId,
};

it("renders with basic props", () => {
render(<TypingFeedback {...defaultProps} />);

expect(screen.getByText("1")).toBeInTheDocument(); // Line number
expect(screen.getByText("1").closest("div")).toBeInTheDocument(); // Component wrapper
});

it("shows correct line count for multi-line content", () => {
const feedback = [
createFeedback({ char: "a", index: 0 }),
createFeedback({ char: "\n", index: 1 }),
createFeedback({ char: "b", index: 2 }),
];

render(<TypingFeedback {...defaultProps} feedback={feedback} />);

expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});

it("applies correct styles based on feedback state", () => {
const feedback = [
createFeedback({ char: "a", index: 0, state: "pending" }),
createFeedback({ char: "b", index: 1, state: "first-hit" }),
createFeedback({ char: "c", index: 2, state: "error", typed: "x" }),
];

render(<TypingFeedback {...defaultProps} feedback={feedback} />);

// Check that component renders without errors
expect(screen.getByText("1")).toBeInTheDocument();
});

it("shows cursor on active character", () => {
const feedback = [
createFeedback({ char: "a", index: 0, state: "pending" }),
createFeedback({ char: "b", index: 1, state: "pending" }),
];

render(<TypingFeedback {...defaultProps} feedback={feedback} typedLength={1} />);

// Check that component renders without errors
expect(screen.getByText("1")).toBeInTheDocument();
});

it("shows typed overlay for errors", () => {
const feedback = [
createFeedback({
char: "a",
index: 0,
state: "error",
typed: "x"
}),
];

render(<TypingFeedback {...defaultProps} feedback={feedback} />);

expect(screen.getByText("x")).toBeInTheDocument();
expect(screen.getByText("x")).toHaveClass("typed-overlay");
});

it("applies syntax highlighting classes", () => {
const feedback = [createFeedback({ char: "a", index: 0 })];
const syntaxTokens = [createSyntaxToken(0, 1, "keyword")];

render(<TypingFeedback {...defaultProps} feedback={feedback} syntaxTokens={syntaxTokens} />);

// Check that component renders without errors
expect(screen.getByText("1")).toBeInTheDocument();
});

it("shows gutter with correct opacity based on hasStarted", () => {
const { rerender } = render(<TypingFeedback {...defaultProps} hasStarted={false} />);

const gutter = screen.getByText("1").parentElement;
expect(gutter).toHaveStyle("opacity: 0");

rerender(<TypingFeedback {...defaultProps} hasStarted={true} />);
expect(gutter).toHaveStyle("opacity: 1");
});

it("applies correct font size", () => {
render(<TypingFeedback {...defaultProps} fontSize={18} />);

// Check that component renders without errors
expect(screen.getByText("1")).toBeInTheDocument();
});

it("handles empty feedback array", () => {
render(<TypingFeedback {...defaultProps} feedback={[]} />);

expect(screen.getByText("1")).toBeInTheDocument(); // Should still show line 1
});

it("shows title attribute for error states with typed characters", () => {
const feedback = [
createFeedback({
char: "a",
index: 0,
state: "error",
typed: "x"
}),
];

render(<TypingFeedback {...defaultProps} feedback={feedback} />);

const span = screen.getByTitle("Typed: x");
expect(span).toBeInTheDocument();
});

it("handles different syntax theme IDs", () => {
const feedback = [createFeedback({ char: "a", index: 0 })];

render(<TypingFeedback {...defaultProps} feedback={feedback} syntaxThemeId="dracula" />);

// Check that component renders without errors
expect(screen.getByText("1")).toBeInTheDocument();
});
});
129 changes: 129 additions & 0 deletions apps/web/src/features/editor/utils/tabAdvance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { computeWhitespaceSegment, syncCaretPosition } from "./tabAdvance";

describe("tabAdvance utilities", () => {
describe("computeWhitespaceSegment", () => {
it("returns empty string for out of bounds index", () => {
expect(computeWhitespaceSegment("hello", 10)).toBe("");
expect(computeWhitespaceSegment("", 0)).toBe("");
});

it("returns empty string when no whitespace at start", () => {
expect(computeWhitespaceSegment("hello", 0)).toBe("");
expect(computeWhitespaceSegment("world", 2)).toBe("");
});

it("finds single space", () => {
expect(computeWhitespaceSegment(" hello", 0)).toBe(" ");
expect(computeWhitespaceSegment("a b", 1)).toBe(" ");
});

it("finds multiple spaces", () => {
expect(computeWhitespaceSegment(" hello", 0)).toBe(" ");
expect(computeWhitespaceSegment("a b", 1)).toBe(" ");
});

it("finds single tab", () => {
expect(computeWhitespaceSegment("\thello", 0)).toBe("\t");
expect(computeWhitespaceSegment("a\tb", 1)).toBe("\t");
});

it("finds multiple tabs", () => {
expect(computeWhitespaceSegment("\t\thello", 0)).toBe("\t\t");
expect(computeWhitespaceSegment("a\t\tb", 1)).toBe("\t\t");
});

it("finds mixed spaces and tabs", () => {
expect(computeWhitespaceSegment(" \t hello", 0)).toBe(" \t ");
expect(computeWhitespaceSegment("a \t b", 1)).toBe(" \t ");
});

it("stops at non-whitespace characters", () => {
expect(computeWhitespaceSegment(" hello world", 0)).toBe(" ");
expect(computeWhitespaceSegment("a hello b", 1)).toBe(" ");
});

it("handles edge cases", () => {
expect(computeWhitespaceSegment(" ", 0)).toBe(" ");
expect(computeWhitespaceSegment("\t", 0)).toBe("\t");
expect(computeWhitespaceSegment(" ", 0)).toBe(" ");
});
});

describe("syncCaretPosition", () => {
let mockRef: { current: Pick<HTMLTextAreaElement, "setSelectionRange"> | null };
let mockSetSelectionRange: ReturnType<typeof vi.fn>;
let mockRequestAnimationFrame: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockSetSelectionRange = vi.fn();
mockRequestAnimationFrame = vi.fn((callback) => {
callback();
return 1;
});

mockRef = {
current: {
setSelectionRange: mockSetSelectionRange,
},
};

// Mock requestAnimationFrame
Object.defineProperty(window, "requestAnimationFrame", {
writable: true,
value: mockRequestAnimationFrame,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("calls setSelectionRange with correct position", () => {
syncCaretPosition(mockRef as any, 5);

expect(mockRequestAnimationFrame).toHaveBeenCalledTimes(1);
expect(mockSetSelectionRange).toHaveBeenCalledWith(5, 5);
});

it("handles null ref gracefully", () => {
const nullRef: { current: null } = { current: null };

expect(() => {
syncCaretPosition(nullRef as any, 5);
}).not.toThrow();

expect(mockSetSelectionRange).not.toHaveBeenCalled();
});

it("handles missing requestAnimationFrame", () => {
// Remove requestAnimationFrame
Object.defineProperty(window, "requestAnimationFrame", {
writable: true,
value: undefined,
});

expect(() => {
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 3);
}).not.toThrow();

expect(mockSetSelectionRange).toHaveBeenCalledWith(3, 3);
});

it("calls setSelectionRange with different positions", () => {
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 0);
expect(mockSetSelectionRange).toHaveBeenCalledWith(0, 0);

syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 10);
expect(mockSetSelectionRange).toHaveBeenCalledWith(10, 10);

syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, 100);
expect(mockSetSelectionRange).toHaveBeenCalledWith(100, 100);
});

it("handles negative positions", () => {
syncCaretPosition(mockRef as unknown as { current: HTMLTextAreaElement | null }, -1);
expect(mockSetSelectionRange).toHaveBeenCalledWith(-1, -1);
});
});
});
Loading
Loading