diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c046aeb..f683f8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index f204039..fe6a99e 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -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", diff --git a/apps/web/src/features/editor/components/TypingFeedback.test.tsx b/apps/web/src/features/editor/components/TypingFeedback.test.tsx new file mode 100644 index 0000000..3e45741 --- /dev/null +++ b/apps/web/src/features/editor/components/TypingFeedback.test.tsx @@ -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 & { children: React.ReactNode }) => ( + + {children} + + ), + }, +})); + +describe("TypingFeedback", () => { + const createFeedback = (overrides: Partial = {}): 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + 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(); + + // Check that component renders without errors + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("shows gutter with correct opacity based on hasStarted", () => { + const { rerender } = render(); + + const gutter = screen.getByText("1").parentElement; + expect(gutter).toHaveStyle("opacity: 0"); + + rerender(); + expect(gutter).toHaveStyle("opacity: 1"); + }); + + it("applies correct font size", () => { + render(); + + // Check that component renders without errors + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("handles empty feedback array", () => { + render(); + + 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(); + + const span = screen.getByTitle("Typed: x"); + expect(span).toBeInTheDocument(); + }); + + it("handles different syntax theme IDs", () => { + const feedback = [createFeedback({ char: "a", index: 0 })]; + + render(); + + // Check that component renders without errors + expect(screen.getByText("1")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/editor/utils/tabAdvance.test.ts b/apps/web/src/features/editor/utils/tabAdvance.test.ts new file mode 100644 index 0000000..a7068ab --- /dev/null +++ b/apps/web/src/features/editor/utils/tabAdvance.test.ts @@ -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 | null }; + let mockSetSelectionRange: ReturnType; + let mockRequestAnimationFrame: ReturnType; + + 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); + }); + }); +}); diff --git a/apps/web/src/features/editor/utils/text.test.ts b/apps/web/src/features/editor/utils/text.test.ts new file mode 100644 index 0000000..84e8c73 --- /dev/null +++ b/apps/web/src/features/editor/utils/text.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { normalizeWhitespace } from "./text"; + +describe("text utilities", () => { + describe("normalizeWhitespace", () => { + it("converts newline to escaped string", () => { + expect(normalizeWhitespace("\n")).toBe("\\n"); + }); + + it("converts tab to escaped string", () => { + expect(normalizeWhitespace("\t")).toBe("\\t"); + }); + + it("returns regular characters unchanged", () => { + expect(normalizeWhitespace("a")).toBe("a"); + expect(normalizeWhitespace(" ")).toBe(" "); + expect(normalizeWhitespace("hello")).toBe("hello"); + expect(normalizeWhitespace("123")).toBe("123"); + }); + + it("handles empty string", () => { + expect(normalizeWhitespace("")).toBe(""); + }); + + it("handles special characters", () => { + expect(normalizeWhitespace("!")).toBe("!"); + expect(normalizeWhitespace("@")).toBe("@"); + expect(normalizeWhitespace("#")).toBe("#"); + expect(normalizeWhitespace("$")).toBe("$"); + }); + + it("handles unicode characters", () => { + expect(normalizeWhitespace("ñ")).toBe("ñ"); + expect(normalizeWhitespace("é")).toBe("é"); + expect(normalizeWhitespace("🚀")).toBe("🚀"); + }); + + it("handles mixed content", () => { + expect(normalizeWhitespace("hello\nworld")).toBe("hello\\nworld"); + expect(normalizeWhitespace("a\tb")).toBe("a\\tb"); + expect(normalizeWhitespace("line1\nline2\tindented")).toBe("line1\\nline2\\tindented"); + }); + + it("handles only whitespace characters", () => { + expect(normalizeWhitespace("\n")).toBe("\\n"); + expect(normalizeWhitespace("\t")).toBe("\\t"); + expect(normalizeWhitespace(" ")).toBe(" "); + }); + + it("handles multiple newlines and tabs", () => { + expect(normalizeWhitespace("\n\n")).toBe("\\n\\n"); + expect(normalizeWhitespace("\t\t")).toBe("\\t\\t"); + expect(normalizeWhitespace("\n\t\n")).toBe("\\n\\t\\n"); + }); + }); +}); diff --git a/apps/web/src/features/editor/utils/text.ts b/apps/web/src/features/editor/utils/text.ts index 7a03034..c74a3b6 100644 --- a/apps/web/src/features/editor/utils/text.ts +++ b/apps/web/src/features/editor/utils/text.ts @@ -1,9 +1,5 @@ export function normalizeWhitespace(value: string): string { - if (value === "\n") { - return "\\n"; - } - if (value === "\t") { - return "\\t"; - } - return value; + return value + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t"); } diff --git a/apps/web/src/features/session/utils/metrics.test.ts b/apps/web/src/features/session/utils/metrics.test.ts new file mode 100644 index 0000000..e2561fa --- /dev/null +++ b/apps/web/src/features/session/utils/metrics.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { calculateMetrics } from "./metrics"; +import type { EngineSummary } from "../engine/engineClient"; + +// Mock performance.now +const mockPerformanceNow = vi.fn(); +Object.defineProperty(global, "performance", { + value: { + now: mockPerformanceNow, + }, + writable: true, +}); + +describe("metrics utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPerformanceNow.mockReturnValue(1000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createEngineSummary = (overrides: Partial = {}): EngineSummary => ({ + target_length: 100, + typed_length: 50, + correct: 45, + incorrect: 5, + accuracy: 0.9, + completion: 0.5, + keystrokes: 50, + feedback: [], + ...overrides, + }); + + describe("calculateMetrics", () => { + it("calculates basic metrics correctly", () => { + const summary = createEngineSummary({ + typed_length: 100, + accuracy: 0.95, + }); + + const startedAt = 0; + const completedAt = 60000; // 1 minute + + const result = calculateMetrics(summary, startedAt, completedAt); + + expect(result.wpm).toBeCloseTo(20, 2); // 100 chars / 5 / 1 minute = 20 WPM + expect(result.accuracy).toBe(0.95); + expect(result.elapsedMs).toBe(60000); + }); + + it("handles zero elapsed time", () => { + const summary = createEngineSummary({ + typed_length: 50, + accuracy: 0.8, + }); + + const result = calculateMetrics(summary, null, null); + + expect(result.wpm).toBe(0); + expect(result.accuracy).toBe(0.8); + expect(result.elapsedMs).toBe(0); + }); + + it("handles null start time", () => { + const summary = createEngineSummary({ + typed_length: 25, + accuracy: 0.9, + }); + + const result = calculateMetrics(summary, null, 30000); + + expect(result.wpm).toBe(0); + expect(result.accuracy).toBe(0.9); + expect(result.elapsedMs).toBe(0); + }); + + it("uses current time when completedAt is null", () => { + const summary = createEngineSummary({ + typed_length: 200, + accuracy: 0.85, + }); + + const startedAt = 0; + mockPerformanceNow.mockReturnValue(120000); // 2 minutes + + const result = calculateMetrics(summary, startedAt, null); + + expect(result.wpm).toBeCloseTo(20, 2); // 200 chars / 5 / 2 minutes = 20 WPM + expect(result.accuracy).toBe(0.85); + expect(result.elapsedMs).toBe(120000); + }); + + it("uses nowOverride when provided", () => { + const summary = createEngineSummary({ + typed_length: 150, + accuracy: 0.92, + }); + + const startedAt = 0; + const nowOverride = 90000; // 1.5 minutes + + const result = calculateMetrics(summary, startedAt, null, nowOverride); + + expect(result.wpm).toBeCloseTo(20, 2); // 150 chars / 5 / 1.5 minutes = 20 WPM + expect(result.accuracy).toBe(0.92); + expect(result.elapsedMs).toBe(90000); + }); + + it("handles very short time periods", () => { + const summary = createEngineSummary({ + typed_length: 10, + accuracy: 1.0, + }); + + const startedAt = 0; + const completedAt = 1000; // 1 second + + const result = calculateMetrics(summary, startedAt, completedAt); + + expect(result.wpm).toBeCloseTo(120, 2); // 10 chars / 5 / (1/60) minutes = 120 WPM + expect(result.accuracy).toBe(1.0); + expect(result.elapsedMs).toBe(1000); + }); + + it("handles zero typed length", () => { + const summary = createEngineSummary({ + typed_length: 0, + accuracy: 0, + }); + + const startedAt = 0; + const completedAt = 60000; + + const result = calculateMetrics(summary, startedAt, completedAt); + + expect(result.wpm).toBe(0); + expect(result.accuracy).toBe(0); + expect(result.elapsedMs).toBe(60000); + }); + + it("handles negative elapsed time", () => { + const summary = createEngineSummary({ + typed_length: 50, + accuracy: 0.8, + }); + + const startedAt = 1000; + const completedAt = 500; // Completed before started + + const result = calculateMetrics(summary, startedAt, completedAt); + + expect(result.wpm).toBe(0); // Should handle gracefully + expect(result.accuracy).toBe(0.8); + expect(result.elapsedMs).toBe(-500); + }); + + it("handles very high WPM", () => { + const summary = createEngineSummary({ + typed_length: 1000, + accuracy: 0.95, + }); + + const startedAt = 0; + const completedAt = 10000; // ~10 seconds + + const result = calculateMetrics(summary, startedAt, completedAt); + + expect(result.wpm).toBeCloseTo(1200, 2); // 1000 chars / 5 / (10/60) minutes = 1200 WPM + expect(result.accuracy).toBe(0.95); + expect(result.elapsedMs).toBe(10000); + }); + + it("handles missing performance object", () => { + // Remove performance object + Object.defineProperty(global, "performance", { + value: undefined, + writable: true, + }); + + const summary = createEngineSummary({ + typed_length: 100, + accuracy: 0.9, + }); + + const startedAt = 0; + const completedAt = 60000; + + const result = calculateMetrics(summary, startedAt, completedAt); + + expect(result.wpm).toBeCloseTo(20, 2); + expect(result.accuracy).toBe(0.9); + expect(result.elapsedMs).toBe(60000); + }); + + it("returns correct type structure", () => { + const summary = createEngineSummary(); + const result = calculateMetrics(summary, 0, 60000); + + expect(result).toHaveProperty("wpm"); + expect(result).toHaveProperty("accuracy"); + expect(result).toHaveProperty("elapsedMs"); + expect(typeof result.wpm).toBe("number"); + expect(typeof result.accuracy).toBe("number"); + expect(typeof result.elapsedMs).toBe("number"); + }); + }); +}); diff --git a/apps/web/src/shared/components/MenuBar.test.tsx b/apps/web/src/shared/components/MenuBar.test.tsx new file mode 100644 index 0000000..c2d610f --- /dev/null +++ b/apps/web/src/shared/components/MenuBar.test.tsx @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MenuBar } from "./MenuBar"; +import type { SyntaxThemeId } from "../syntax/themes"; + +// Mock the styles module +vi.mock("./MenuBar.styles", () => ({ + menuBarStyles: { + root: "menu-bar-root", + trafficCluster: "traffic-cluster", + closeButton: "close-button", + closeGlyph: "close-glyph", + titleGroup: "title-group", + brand: "brand", + brandText: "brand-text", + promptName: "prompt-name", + actions: "actions", + toggleButton: (isOpen: boolean) => `toggle-button ${isOpen ? "open" : "closed"}`, + toggleIcon: "toggle-icon", + toggleIconLineTop: (isOpen: boolean) => `toggle-line-top ${isOpen ? "open" : "closed"}`, + toggleIconLineMiddle: (isOpen: boolean) => `toggle-line-middle ${isOpen ? "open" : "closed"}`, + toggleIconLineBottom: (isOpen: boolean) => `toggle-line-bottom ${isOpen ? "open" : "closed"}`, + }, + menuBarIconStyles: { + zoomButton: (isMaximized: boolean) => `zoom-button ${isMaximized ? "maximized" : "normal"}`, + zoomGlyph: "zoom-glyph", + }, +})); + +// Mock the SyntaxThemeSwitcher component +vi.mock("./SyntaxThemeSwitcher", () => ({ + SyntaxThemeSwitcher: ({ themeId, onChange }: { themeId: string; onChange: (theme: string) => void }) => ( +
onChange("new-theme")}> + Theme Switcher +
+ ), +})); + +describe("MenuBar", () => { + const defaultProps = { + syntaxThemeId: "vscode-dark" as SyntaxThemeId, + onSyntaxThemeChange: vi.fn(), + }; + + it("renders with basic props", () => { + render(); + + expect(screen.getByText("devkeys")).toBeInTheDocument(); + expect(screen.getByLabelText("Close")).toBeInTheDocument(); + expect(screen.getByLabelText("Enter fullscreen")).toBeInTheDocument(); + expect(screen.getByLabelText("Toggle explorer")).toBeInTheDocument(); + }); + + it("displays prompt name when provided", () => { + render(); + + expect(screen.getByText("Binary Search")).toBeInTheDocument(); + }); + + it("shows correct explorer toggle state", () => { + const { rerender } = render(); + + const toggleButton = screen.getByLabelText("Toggle explorer"); + expect(toggleButton).toHaveAttribute("title", "Show explorer"); + + rerender(); + expect(toggleButton).toHaveAttribute("title", "Hide explorer"); + }); + + it("shows correct maximize state", () => { + const { rerender } = render(); + + expect(screen.getByLabelText("Enter fullscreen")).toBeInTheDocument(); + + rerender(); + expect(screen.getByLabelText("Restore window")).toBeInTheDocument(); + }); + + it("calls onToggleExplorer when toggle button is clicked", () => { + const onToggleExplorer = vi.fn(); + render(); + + const toggleButton = screen.getByLabelText("Toggle explorer"); + fireEvent.click(toggleButton); + + expect(onToggleExplorer).toHaveBeenCalledTimes(1); + }); + + it("calls onDragPointerDown when dragging", () => { + const onDragPointerDown = vi.fn(); + render(); + + const menuBar = screen.getByText("devkeys").closest("div"); + fireEvent.pointerDown(menuBar!); + + expect(onDragPointerDown).toHaveBeenCalledTimes(1); + }); + + it("calls onDoubleClick when double-clicked", () => { + const onDoubleClick = vi.fn(); + render(); + + const menuBar = screen.getByText("devkeys").closest("div"); + fireEvent.doubleClick(menuBar!); + + expect(onDoubleClick).toHaveBeenCalledTimes(1); + }); + + it("handles syntax theme changes", () => { + const onSyntaxThemeChange = vi.fn(); + render(); + + const themeSwitcher = screen.getByTestId("syntax-theme-switcher"); + fireEvent.click(themeSwitcher); + + expect(onSyntaxThemeChange).toHaveBeenCalledWith("new-theme"); + }); + + it("has correct data attributes for window actions", () => { + render(); + + const closeButton = screen.getByLabelText("Close"); + const zoomButton = screen.getByLabelText("Enter fullscreen"); + + expect(closeButton).toHaveAttribute("data-action", "close"); + expect(zoomButton).toHaveAttribute("data-action", "toggle-maximize"); + }); + + it("prevents dragging on interactive elements", () => { + render(); + + const toggleButton = screen.getByLabelText("Toggle explorer"); + expect(toggleButton).toHaveAttribute("data-no-drag"); + }); +}); diff --git a/apps/web/src/shared/hooks/useWindowFrame.test.ts b/apps/web/src/shared/hooks/useWindowFrame.test.ts new file mode 100644 index 0000000..80a8d89 --- /dev/null +++ b/apps/web/src/shared/hooks/useWindowFrame.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useWindowFrame } from "./useWindowFrame"; + +// Mock ResizeObserver +const mockResizeObserver = vi.fn(); +mockResizeObserver.mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +Object.defineProperty(window, "ResizeObserver", { + writable: true, + value: mockResizeObserver, +}); + +// Mock getComputedStyle +const mockGetComputedStyle = vi.fn(); +Object.defineProperty(window, "getComputedStyle", { + writable: true, + value: mockGetComputedStyle, +}); + +// Mock getBoundingClientRect +const mockGetBoundingClientRect = vi.fn(); + +describe("useWindowFrame", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock implementations + mockGetComputedStyle.mockReturnValue({ + paddingLeft: "16px", + paddingRight: "16px", + paddingTop: "16px", + paddingBottom: "16px", + }); + + mockGetBoundingClientRect.mockReturnValue({ + width: 800, + height: 600, + top: 0, + left: 0, + right: 800, + bottom: 600, + }); + + // Mock window dimensions + Object.defineProperty(window, "innerWidth", { + writable: true, + value: 1920, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + value: 1080, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns initial state", () => { + const { result } = renderHook(() => useWindowFrame()); + + expect(result.current.isClosed).toBe(false); + expect(result.current.isMaximized).toBe(false); + expect(result.current.frameRef).toBeDefined(); + expect(result.current.containerRef).toBeDefined(); + expect(typeof result.current.onDragPointerDown).toBe("function"); + expect(typeof result.current.onDragPointerMove).toBe("function"); + expect(typeof result.current.onDragPointerUp).toBe("function"); + expect(typeof result.current.onWindowActionClick).toBe("function"); + expect(typeof result.current.recenterWindow).toBe("function"); + expect(typeof result.current.openWindow).toBe("function"); + }); + + it("handles window close action", () => { + const { result } = renderHook(() => useWindowFrame()); + + act(() => { + result.current.openWindow(); + }); + + expect(result.current.isClosed).toBe(false); + + // Simulate close button click + const mockEvent = { + target: document.createElement("button"), + currentTarget: document.createElement("div"), + } as any; + + mockEvent.target.setAttribute("data-action", "close"); + + act(() => { + result.current.onWindowActionClick(mockEvent); + }); + + expect(result.current.isClosed).toBe(true); + }); + + it("handles window maximize toggle", () => { + const { result } = renderHook(() => useWindowFrame()); + + expect(result.current.isMaximized).toBe(false); + + // Simulate maximize button click + const mockEvent = { + target: document.createElement("button"), + currentTarget: document.createElement("div"), + } as any; + + mockEvent.target.setAttribute("data-action", "toggle-maximize"); + + act(() => { + result.current.onWindowActionClick(mockEvent); + }); + + expect(result.current.isMaximized).toBe(true); + + // Toggle back + act(() => { + result.current.onWindowActionClick(mockEvent); + }); + + expect(result.current.isMaximized).toBe(false); + }); + + it("handles drag operations", () => { + const { result } = renderHook(() => useWindowFrame()); + + // Mock frame element + const mockFrame = document.createElement("div"); + mockFrame.style.transform = "translate(0px, 0px)"; + result.current.frameRef.current = mockFrame; + + // Start drag + const pointerDownEvent = { + button: 0, + clientX: 100, + clientY: 100, + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerDown(pointerDownEvent); + }); + + // Move during drag + const pointerMoveEvent = { + clientX: 150, + clientY: 150, + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerMove(pointerMoveEvent); + }); + + // End drag + const pointerUpEvent = { + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerUp(pointerUpEvent); + }); + + // Should not throw and should update position + expect(mockFrame.style.transform).toContain("translate"); + }); + + it("prevents dragging on interactive elements", () => { + const { result } = renderHook(() => useWindowFrame()); + + // Mock frame element + const mockFrame = document.createElement("div"); + mockFrame.style.transform = "translate(0px, 0px)"; + result.current.frameRef.current = mockFrame; + + // Create a button element + const button = document.createElement("button"); + button.setAttribute("data-no-drag", "true"); + + const pointerDownEvent = { + button: 0, + clientX: 100, + clientY: 100, + target: button, + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerDown(pointerDownEvent); + }); + + // Should not start dragging + const pointerMoveEvent = { + clientX: 150, + clientY: 150, + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerMove(pointerMoveEvent); + }); + + // Position should not change + expect(mockFrame.style.transform).toBe("translate(0px, 0px)"); + }); + + it("recenters window", () => { + const { result } = renderHook(() => useWindowFrame()); + + // Mock frame element + const mockFrame = document.createElement("div"); + mockFrame.style.transform = "translate(100px, 100px)"; + result.current.frameRef.current = mockFrame; + + act(() => { + result.current.recenterWindow(); + }); + + expect(mockFrame.style.transform).toBe("translate(0px, 0px)"); + }); + + it("opens window", () => { + const { result } = renderHook(() => useWindowFrame()); + + // First close the window + const mockEvent = { + target: document.createElement("button"), + currentTarget: document.createElement("div"), + } as any; + mockEvent.target.setAttribute("data-action", "close"); + + act(() => { + result.current.onWindowActionClick(mockEvent); + }); + + expect(result.current.isClosed).toBe(true); + + // Then open it + act(() => { + result.current.openWindow(); + }); + + expect(result.current.isClosed).toBe(false); + expect(result.current.isMaximized).toBe(false); + }); + + it("handles non-left mouse button clicks", () => { + const { result } = renderHook(() => useWindowFrame()); + + // Mock frame element + const mockFrame = document.createElement("div"); + mockFrame.style.transform = "translate(0px, 0px)"; + result.current.frameRef.current = mockFrame; + + const pointerDownEvent = { + button: 1, // Right mouse button + clientX: 100, + clientY: 100, + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerDown(pointerDownEvent); + }); + + // Should not start dragging + const pointerMoveEvent = { + clientX: 150, + clientY: 150, + currentTarget: document.createElement("div"), + } as any; + + act(() => { + result.current.onDragPointerMove(pointerMoveEvent); + }); + + // Position should not change + expect(mockFrame.style.transform).toBe("translate(0px, 0px)"); + }); + + it("handles missing action elements gracefully", () => { + const { result } = renderHook(() => useWindowFrame()); + + const mockEvent = { + target: document.createElement("div"), + currentTarget: document.createElement("div"), + } as any; + + // Should not throw + expect(() => { + act(() => { + result.current.onWindowActionClick(mockEvent); + }); + }).not.toThrow(); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7e5918d..a9920be 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -9,14 +9,24 @@ "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "types": ["vite/client"], + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client", + "vitest/globals", + "@testing-library/jest-dom" + ], "skipLibCheck": true }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "./tsconfig.node.json" } ] -} +} \ No newline at end of file diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts index d0de870..e5a2d01 100644 --- a/apps/web/vitest.setup.ts +++ b/apps/web/vitest.setup.ts @@ -1 +1,5 @@ import "@testing-library/jest-dom"; +import { expect } from "vitest"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +expect.extend(matchers); diff --git a/crates/engine/src/diff.rs b/crates/engine/src/diff.rs index f59da2c..f1f72d1 100644 --- a/crates/engine/src/diff.rs +++ b/crates/engine/src/diff.rs @@ -15,24 +15,3 @@ pub fn diff_counts_from_chars(target: &[char], typed: &[char]) -> (usize, usize) (correct, incorrect) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn counts_diff_within_bounds() { - let target: Vec = "hello".chars().collect(); - let typed: Vec = "hez".chars().collect(); - let (correct, incorrect) = diff_counts_from_chars(&target, &typed); - assert_eq!((correct, incorrect), (2, 1)); - } - - #[test] - fn extra_typed_are_incorrect() { - let target: Vec = "abc".chars().collect(); - let typed: Vec = "abcd".chars().collect(); - let (_, incorrect) = diff_counts_from_chars(&target, &typed); - assert_eq!(incorrect, 1); - } -} diff --git a/crates/engine/src/engine.rs b/crates/engine/src/engine.rs index d4a5a1d..d5e48ce 100644 --- a/crates/engine/src/engine.rs +++ b/crates/engine/src/engine.rs @@ -69,41 +69,3 @@ pub fn evaluate_typed( Err(_err) => JsValue::NULL, } } - -#[cfg(test)] -mod tests { - use crate::diff::diff_counts_from_chars; - use crate::feedback::build_feedback_from_chars; - use crate::types::FeedbackState; - - #[test] - fn diff_counts_tracks_correct_and_incorrect() { - let target: Vec = "fn add(a: i32, b: i32) -> i32 { a + b }".chars().collect(); - let typed: Vec = "fn add(a: i32, b: i32) -> i32 { a + c }".chars().collect(); - let (correct, incorrect) = diff_counts_from_chars(&target, &typed); - assert!(correct > 0); - assert!(incorrect > 0); - assert_eq!(correct + incorrect, typed.len()); - } - - #[test] - fn extra_characters_are_treated_as_incorrect() { - let target: Vec = "let x = 5;".chars().collect(); - let typed: Vec = "let x = 5;;".chars().collect(); - let (_, incorrect) = diff_counts_from_chars(&target, &typed); - assert_eq!(incorrect, 1); - } - - #[test] - fn build_feedback_matches_attempt_logic() { - let target: Vec = "abc".chars().collect(); - let typed: Vec = "axc".chars().collect(); - let attempts = vec![1, 3, 1]; - let feedback = build_feedback_from_chars(&target, &typed, &attempts); - assert_eq!(feedback.len(), 3); - assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); - assert!(matches!(feedback[1].state, FeedbackState::Error)); - assert_eq!(feedback[1].typed, Some('x')); - assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); - } -} diff --git a/crates/engine/src/feedback.rs b/crates/engine/src/feedback.rs index 334bcf2..614fdea 100644 --- a/crates/engine/src/feedback.rs +++ b/crates/engine/src/feedback.rs @@ -31,18 +31,3 @@ pub fn build_feedback_from_chars( results } - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::FeedbackState; - - #[test] - fn pending_when_not_typed() { - let target: Vec = "abc".chars().collect(); - let typed: Vec = "a".chars().collect(); - let feedback = build_feedback_from_chars(&target, &typed, &[0, 0, 0]); - assert!(matches!(feedback[1].state, FeedbackState::Pending)); - assert!(matches!(feedback[2].state, FeedbackState::Pending)); - } -} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 533d84b..7eeba53 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,6 +1,6 @@ -mod diff; -mod engine; -mod feedback; +pub mod diff; +pub mod engine; +pub mod feedback; pub mod types; pub use engine::evaluate_typed; diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index 1d7328e..b8f221f 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -1,7 +1,7 @@ -use serde::Serialize; +use serde::{Serialize, Deserialize}; /// Summary returned after evaluating a typed snippet against the target. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct EvaluationSummary { pub target_length: usize, pub typed_length: usize, @@ -14,7 +14,7 @@ pub struct EvaluationSummary { } /// Character-level feedback encoded for the client UI. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct CharacterFeedback { pub index: usize, #[serde(rename = "char")] @@ -24,7 +24,7 @@ pub struct CharacterFeedback { pub state: FeedbackState, } -#[derive(Debug, Serialize, Clone, Copy)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] #[serde(rename_all = "kebab-case")] pub enum FeedbackState { Pending, diff --git a/crates/engine/tests/integration_tests.rs b/crates/engine/tests/integration_tests.rs new file mode 100644 index 0000000..439fb35 --- /dev/null +++ b/crates/engine/tests/integration_tests.rs @@ -0,0 +1,220 @@ +// Integration tests for the devkeys engine +// These test the complete functionality including WASM bindings + +use devkeys_engine::*; + +// WASM-specific tests (only run in WASM environment) +#[cfg(target_arch = "wasm32")] +mod wasm_tests { + use super::*; + use js_sys::Uint32Array; + use wasm_bindgen::prelude::*; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn test_evaluate_typed_perfect_match() { + let target = "hello world"; + let typed = "hello world"; + let attempts = Uint32Array::from(&[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1][..]); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } + + #[wasm_bindgen_test] + fn test_evaluate_typed_with_errors() { + let target = "hello world"; + let typed = "helxo world"; + let attempts = Uint32Array::from(&[1, 1, 1, 2, 1, 0, 1, 1, 1, 1, 1][..]); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } + + #[wasm_bindgen_test] + fn test_evaluate_typed_empty_target() { + let target = ""; + let typed = "hello"; + let attempts = Uint32Array::new(&JsValue::UNDEFINED); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } + + #[wasm_bindgen_test] + fn test_evaluate_typed_empty_typed() { + let target = "hello world"; + let typed = ""; + let attempts = Uint32Array::new(&JsValue::UNDEFINED); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } + + #[wasm_bindgen_test] + fn test_evaluate_typed_unicode_characters() { + let target = "café naïve"; + let typed = "café naïve"; + let attempts = Uint32Array::from(&[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1][..]); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } + + #[wasm_bindgen_test] + fn test_evaluate_typed_partial_word_completion() { + let target = "hello world test"; + let typed = "hello world"; + let attempts = Uint32Array::from(&[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1][..]); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } + + #[wasm_bindgen_test] + fn test_evaluate_typed_high_attempt_counts() { + let target = "abc"; + let typed = "axc"; + let attempts = Uint32Array::from(&[1, 10, 1][..]); + + let result = evaluate_typed(target, typed, &attempts, "javascript"); + assert!(!result.is_null()); + } +} + +// Regular integration tests for non-WASM targets +#[cfg(not(target_arch = "wasm32"))] +mod regular_tests { + use super::*; + use devkeys_engine::diff::diff_counts_from_chars; + use devkeys_engine::feedback::build_feedback_from_chars; + + #[test] + fn test_integration_perfect_typing() { + let target: Vec = "hello world".chars().collect(); + let typed: Vec = "hello world".chars().collect(); + let attempts = vec![1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert_eq!(correct, 11); + assert_eq!(incorrect, 0); + assert_eq!(feedback.len(), 11); + } + + #[test] + fn test_integration_with_errors() { + let target: Vec = "hello world".chars().collect(); + let typed: Vec = "helxo world".chars().collect(); + let attempts = vec![1, 1, 1, 2, 1, 0, 1, 1, 1, 1, 1]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert_eq!(correct, 10); + assert_eq!(incorrect, 1); + assert_eq!(feedback.len(), 11); + } + + #[test] + fn test_integration_unicode_handling() { + let target: Vec = "café naïve".chars().collect(); + let typed: Vec = "cafe naive".chars().collect(); + let attempts = vec![1, 1, 1, 1, 0, 1, 1, 1, 1, 1]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert!(correct > 0); + assert!(incorrect > 0); + assert_eq!(feedback.len(), 10); // "café naïve" has 10 characters, not 11 + } + + #[test] + fn test_integration_partial_typing() { + let target: Vec = "hello world".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let attempts = vec![1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert_eq!(correct, 5); + assert_eq!(incorrect, 0); + assert_eq!(feedback.len(), 11); + } + + #[test] + fn test_integration_complex_typing_session() { + let target: Vec = "fn add(a: i32, b: i32) -> i32 { a + b }".chars().collect(); + let typed: Vec = "fn add(a: i32, b: i32) -> i32 { a + c }".chars().collect(); + let attempts = vec![ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + ]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert!(correct > 0); + assert!(incorrect > 0); + assert_eq!(feedback.len(), target.len()); + } + + #[test] + fn test_integration_edge_cases() { + // Empty strings + let target: Vec = "".chars().collect(); + let typed: Vec = "".chars().collect(); + let attempts = vec![]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert_eq!(correct, 0); + assert_eq!(incorrect, 0); + assert_eq!(feedback.len(), 0); + } + + #[test] + fn test_integration_rust_code_typing() { + let target: Vec = "fn main() {\n println!(\"Hello, world!\");\n}" + .chars() + .collect(); + let typed: Vec = "fn main() {\n println!(\"Hello, world!\");\n}" + .chars() + .collect(); + let attempts = vec![1; target.len()]; + + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert_eq!(correct, target.len()); + assert_eq!(incorrect, 0); + assert_eq!(feedback.len(), target.len()); + + // Check that all feedback states are FirstHit + for fb in &feedback { + assert!(matches!(fb.state, FeedbackState::FirstHit)); + } + } + + #[test] + fn test_integration_mixed_attempt_counts() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let attempts = vec![1, 2, 3, 1, 5]; // Mixed attempt counts + + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + + assert_eq!(feedback.len(), 5); + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); // 1 attempt + assert!(matches!(feedback[1].state, FeedbackState::SecondHit)); // 2 attempts + assert!(matches!(feedback[2].state, FeedbackState::MultiHit)); // 3 attempts + assert!(matches!(feedback[3].state, FeedbackState::FirstHit)); // 1 attempt + assert!(matches!(feedback[4].state, FeedbackState::MultiHit)); // 5 attempts + } +} diff --git a/crates/engine/tests/unit_tests.rs b/crates/engine/tests/unit_tests.rs new file mode 100644 index 0000000..1bde76e --- /dev/null +++ b/crates/engine/tests/unit_tests.rs @@ -0,0 +1,278 @@ +// Unit tests for the devkeys engine +// These test individual functions and modules + +use devkeys_engine::diff::diff_counts_from_chars; +use devkeys_engine::feedback::build_feedback_from_chars; +use devkeys_engine::*; + +mod diff_tests { + use super::*; + + #[test] + fn counts_diff_within_bounds() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "hez".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (2, 1)); + } + + #[test] + fn extra_typed_are_incorrect() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "abcd".chars().collect(); + let (_, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!(incorrect, 1); + } + + #[test] + fn perfect_match() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (5, 0)); + } + + #[test] + fn complete_mismatch() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "world".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (1, 4)); // 'o' matches at position 4 + } + + #[test] + fn empty_target() { + let target: Vec = "".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (0, 5)); + } + + #[test] + fn empty_typed() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (0, 0)); + } + + #[test] + fn both_empty() { + let target: Vec = "".chars().collect(); + let typed: Vec = "".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (0, 0)); + } + + #[test] + fn unicode_characters() { + let target: Vec = "café".chars().collect(); + let typed: Vec = "cafe".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (3, 1)); + } + + #[test] + fn many_extra_typed() { + let target: Vec = "a".chars().collect(); + let typed: Vec = "abcdefghij".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (1, 9)); + } + + #[test] + fn single_character_difference() { + let target: Vec = "hello world".chars().collect(); + let typed: Vec = "hello_world".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (10, 1)); + } + + #[test] + fn case_sensitivity() { + let target: Vec = "Hello".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!((correct, incorrect), (4, 1)); + } +} + +mod feedback_tests { + use super::*; + + #[test] + fn pending_when_not_typed() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "a".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[0, 0, 0]); + assert!(matches!(feedback[1].state, FeedbackState::Pending)); + assert!(matches!(feedback[2].state, FeedbackState::Pending)); + } + + #[test] + fn first_hit_on_correct_character() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "abc".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[1, 1, 1]); + + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + assert_eq!(feedback[0].character, 'a'); + assert_eq!(feedback[1].character, 'b'); + assert_eq!(feedback[2].character, 'c'); + } + + #[test] + fn second_hit_on_second_attempt() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "abc".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[2, 2, 2]); + + assert!(matches!(feedback[0].state, FeedbackState::SecondHit)); + assert!(matches!(feedback[1].state, FeedbackState::SecondHit)); + assert!(matches!(feedback[2].state, FeedbackState::SecondHit)); + } + + #[test] + fn multi_hit_on_many_attempts() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "abc".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[5, 3, 10]); + + assert!(matches!(feedback[0].state, FeedbackState::MultiHit)); + assert!(matches!(feedback[1].state, FeedbackState::MultiHit)); + assert!(matches!(feedback[2].state, FeedbackState::MultiHit)); + } + + #[test] + fn error_state_on_wrong_character() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "axc".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[1, 1, 1]); + + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::Error)); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + assert_eq!(feedback[1].typed, Some('x')); + } + + #[test] + fn zero_attempts_are_first_hit() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "abc".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[0, 0, 0]); + + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + } + + #[test] + fn partial_typing_with_mixed_states() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "hel".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[1, 1, 1, 0, 0]); + + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[3].state, FeedbackState::Pending)); + assert!(matches!(feedback[4].state, FeedbackState::Pending)); + } + + #[test] + fn unicode_characters_in_feedback() { + let target: Vec = "café".chars().collect(); + let typed: Vec = "cafe".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[1, 1, 1, 1]); + + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[3].state, FeedbackState::Error)); + assert_eq!(feedback[3].character, 'é'); + assert_eq!(feedback[3].typed, Some('e')); + } + + #[test] + fn empty_target_returns_empty_feedback() { + let target: Vec = "".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[]); + + assert_eq!(feedback.len(), 0); + } + + #[test] + fn empty_typed_returns_pending_feedback() { + let target: Vec = "hello".chars().collect(); + let typed: Vec = "".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[0, 0, 0, 0, 0]); + + assert_eq!(feedback.len(), 5); + for i in 0..5 { + assert!(matches!(feedback[i].state, FeedbackState::Pending)); + } + } + + #[test] + fn feedback_preserves_character_indices() { + let target: Vec = "hello world".chars().collect(); + let typed: Vec = "hello".chars().collect(); + let feedback = + build_feedback_from_chars(&target, &typed, &[1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]); + + for (i, fb) in feedback.iter().enumerate() { + assert_eq!(fb.index, i); + assert_eq!(fb.character, target[i]); + } + } + + #[test] + fn high_attempt_counts_with_errors() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "axc".chars().collect(); + let feedback = build_feedback_from_chars(&target, &typed, &[1, 10, 1]); + + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::Error)); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + assert_eq!(feedback[1].typed, Some('x')); + } +} + +mod engine_tests { + use super::*; + + #[test] + fn diff_counts_tracks_correct_and_incorrect() { + let target: Vec = "fn add(a: i32, b: i32) -> i32 { a + b }".chars().collect(); + let typed: Vec = "fn add(a: i32, b: i32) -> i32 { a + c }".chars().collect(); + let (correct, incorrect) = diff_counts_from_chars(&target, &typed); + assert!(correct > 0); + assert!(incorrect > 0); + assert_eq!(correct + incorrect, typed.len()); + } + + #[test] + fn extra_characters_are_treated_as_incorrect() { + let target: Vec = "let x = 5;".chars().collect(); + let typed: Vec = "let x = 5;;".chars().collect(); + let (_, incorrect) = diff_counts_from_chars(&target, &typed); + assert_eq!(incorrect, 1); + } + + #[test] + fn build_feedback_matches_attempt_logic() { + let target: Vec = "abc".chars().collect(); + let typed: Vec = "axc".chars().collect(); + let attempts = vec![1, 3, 1]; + let feedback = build_feedback_from_chars(&target, &typed, &attempts); + assert_eq!(feedback.len(), 3); + assert!(matches!(feedback[0].state, FeedbackState::FirstHit)); + assert!(matches!(feedback[1].state, FeedbackState::Error)); + assert_eq!(feedback[1].typed, Some('x')); + assert!(matches!(feedback[2].state, FeedbackState::FirstHit)); + } +}