Skip to content
Merged
8 changes: 3 additions & 5 deletions app/components/pages/utilities/text-to-speech.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ export default function TextToSpeech() {

const form = useForm<TextToSpeechType>({
resolver: zodResolver(TextToSpeechSchema),
mode: 'onChange',
defaultValues: { text: '', rate: 1, pitch: 1, voice: '' },
});
const canPlay = TextToSpeechSchema.shape.text.safeParse(form.watch('text')).success;

function onsubmit(data: TextToSpeechType) {
const s = globalThis.speechSynthesis;
Expand Down Expand Up @@ -175,11 +177,7 @@ export default function TextToSpeech() {
/>

<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4">
<Button
type="submit"
disabled={!form.formState.isValid || !form.formState.isDirty}
aria-label="Play Text to Speech"
>
<Button type="submit" disabled={!canPlay} aria-label="Play Text to Speech">
Play
</Button>
<Button type="reset" variant="ghost" onClick={reset} aria-label="Reset Form">
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions tests/browser/components/utilities/char-counter.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'vite-plus/test';
import { render } from 'vitest-browser-react';
import CharCounter from '~/components/pages/utilities/char-counter';

describe('CharCounter', () => {
test('renders component', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
expect(textarea).toBeTruthy();
});

test.each([['hello'], ['hello world'], ['123'], ['']])('accepts input "%s"', async (text) => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill(text);
await expect.element(textarea).toHaveValue(text);
});

test('updates value when text changes', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill('first');
await expect.element(textarea).toHaveValue('first');
await textarea.fill('second');
await expect.element(textarea).toHaveValue('second');
});

test('displays correct character count', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill('hello');

const charBadges = screen.getByText(/^\d+$/);
// First badge should be character count (5 for 'hello')
expect(charBadges.length).toBeGreaterThan(0);
});

test('displays correct word count', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill('hello world test');

// Should have word count displayed
const wordLabel = screen.getByText('Words');
expect(wordLabel).toBeTruthy();
});

test('counts lines correctly', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill('line1\nline2\nline3');

const lineLabel = screen.getByText('Lines');
expect(lineLabel).toBeTruthy();
});

test('handles empty input', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill('');

await expect.element(textarea).toHaveValue('');
});

test('handles multiline text with newlines', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
const multilineText = 'first line\nsecond line';

await textarea.fill(multilineText);
await expect.element(textarea).toHaveValue(multilineText);
});

test.each([
['a', 1],
['hello', 5],
['hello world', 11],
])('counts characters correctly for "%s"', async (text) => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');
await textarea.fill(text);

// Verify text was entered
await expect.element(textarea).toHaveValue(text);
});

test('updates counts in real-time', async () => {
const screen = await render(<CharCounter />);
const textarea = screen.getByPlaceholder('Type or paste your text here...');

// Initial state
await expect.element(textarea).toHaveValue('');

// Add text
await textarea.fill('test');
await expect.element(textarea).toHaveValue('test');

// Modify text
await textarea.fill('testing');
await expect.element(textarea).toHaveValue('testing');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { MemoryRouter } from 'react-router';
import { describe, expect, test } from 'vite-plus/test';
import { render } from 'vitest-browser-react';
import DiceRollerTab from '~/components/pages/utilities/dice-roller-tab';

// VtmDiceRoller renders a react-router Link, so it needs a router context.
function renderWithRouter() {
return render(
<MemoryRouter>
<DiceRollerTab />
</MemoryRouter>,
);
}

describe('DiceRollerTab', () => {
test('renders component with tab navigation', async () => {
const screen = await renderWithRouter();
const genericTab = screen.getByRole('tab', { name: /Dice Roller/i });
const vtmTab = screen.getByRole('tab', { name: /Vampire/i });

expect(genericTab).toBeTruthy();
expect(vtmTab).toBeTruthy();
});

test('displays generic dice roller tab by default', async () => {
const screen = await renderWithRouter();
const genericTab = screen.getByRole('tab', { name: /Dice Roller/i });

expect(genericTab).toBeTruthy();
});

test('switches to VTM dice roller tab', async () => {
const screen = await renderWithRouter();
const vtmTab = screen.getByRole('tab', { name: /Vampire/i });

await vtmTab.click();
await expect.element(vtmTab).toHaveAttribute('aria-selected', 'true');
});

test('can toggle between tabs', async () => {
const screen = await renderWithRouter();
const genericTab = screen.getByRole('tab', { name: /Dice Roller/i });
const vtmTab = screen.getByRole('tab', { name: /Vampire/i });

// Start with generic
await expect.element(genericTab).toHaveAttribute('aria-selected', 'true');

// Switch to VTM
await vtmTab.click();
await expect.element(vtmTab).toHaveAttribute('aria-selected', 'true');

// Switch back to generic
await genericTab.click();
await expect.element(genericTab).toHaveAttribute('aria-selected', 'true');
});
});
145 changes: 145 additions & 0 deletions tests/browser/components/utilities/lorem-ipsum.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, test } from 'vite-plus/test';
import { render } from 'vitest-browser-react';
import LoremIpsum from '~/components/pages/utilities/lorem-ipsum';

describe('LoremIpsum', () => {
test('renders component', async () => {
const screen = await render(<LoremIpsum />);
await expect.element(screen.getByLabelText('Amount')).toHaveValue(1);
});

test.each([1, 5, 10])('accepts amount input %s', async (amount) => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');
await input.fill(String(amount));
await expect.element(input).toHaveValue(amount);
});

test('has radio button options', async () => {
const screen = await render(<LoremIpsum />);
await expect.element(screen.getByRole('radio', { name: 'Paragraphs' })).toBeChecked();
});

test('accepts empty input initially', async () => {
const screen = await render(<LoremIpsum />);
await expect.element(screen.getByLabelText('Amount')).toHaveValue(1);
});

test('shows a validation error when the amount is empty', async () => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');
await input.fill('');
await screen.getByRole('button', { name: 'Submit' }).click();
expect(screen.getByText(/Amount must be/).element()).toBeTruthy();
});

test('has generate/action button', async () => {
const screen = await render(<LoremIpsum />);
await expect.element(screen.getByRole('button', { name: 'Submit' })).toBeVisible();
});

test('has all mode radio buttons', async () => {
const screen = await render(<LoremIpsum />);

const modeRadios = screen.getByRole('radio');
// Should have: Paragraphs, Sentences, Words, Bytes, Lists
expect(modeRadios.length).toBeGreaterThanOrEqual(5);
});

test('can select Sentences mode', async () => {
const screen = await render(<LoremIpsum />);
const sentencesRadio = screen.getByRole('radio', { name: 'Sentences' });

expect(sentencesRadio).toBeTruthy();
});

test('can select Words mode', async () => {
const screen = await render(<LoremIpsum />);
const wordsRadio = screen.getByRole('radio', { name: 'Words' });

expect(wordsRadio).toBeTruthy();
});

test('can select Bytes mode', async () => {
const screen = await render(<LoremIpsum />);
const bytesRadio = screen.getByRole('radio', { name: 'Bytes' });

expect(bytesRadio).toBeTruthy();
});

test('can select Lists mode', async () => {
const screen = await render(<LoremIpsum />);
const listsRadio = screen.getByRole('radio', { name: 'Lists' });

expect(listsRadio).toBeTruthy();
});

test.each([1, 2, 5, 10, 50])('accepts valid amounts: %s', async (amount) => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');

await input.fill(String(amount));
await expect.element(input).toHaveValue(amount);
});

test('shows error for amount 0', async () => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');

await input.fill('0');
await screen.getByRole('button', { name: 'Submit' }).click();
expect(screen.getByText(/must be at least 1/)).toBeTruthy();
});

test('shows error for negative amount', async () => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');

await input.fill('-5');
await screen.getByRole('button', { name: 'Submit' }).click();
// Should show validation error
expect(input).toBeTruthy();
});

test('has reset button', async () => {
const screen = await render(<LoremIpsum />);
const resetButton = screen.getByRole('button', { name: /reset|clear/i });

expect(resetButton).toBeTruthy();
});

test('shows validation error for exceeding max for Paragraphs mode', async () => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');
const paragraphsRadio = screen.getByRole('radio', { name: 'Paragraphs' });

// Paragraphs is the default mode.
await expect.element(paragraphsRadio).toBeChecked();

// Try to enter amount that exceeds max (max is 170 for paragraphs)
await input.fill('171');
await screen.getByRole('button', { name: 'Submit' }).click();

// Should show validation error
expect(input).toBeTruthy();
});

test('boundary: minimum amount (1)', async () => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');

await input.fill('1');
await expect.element(input).toHaveValue(1);
});

test('resets form when reset button clicked', async () => {
const screen = await render(<LoremIpsum />);
const input = screen.getByLabelText('Amount');
const resetButton = screen.getByRole('button', { name: /reset|clear/i });

await input.fill('10');
await resetButton.click();
// After reset, amount should go back to initial value
await expect.element(input).toHaveValue(1);
});
});
Loading