Skip to content

Commit ea4915e

Browse files
authored
Merge pull request #5 from bocan:feat/more-tools
Add new tools: Lorem Generator, Timestamp Converter, and UUID Generator
2 parents f8ba711 + 2fe0e17 commit ea4915e

21 files changed

Lines changed: 1940 additions & 62 deletions

.github/workflows/node.js.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ name: CI & Deploy
55
on:
66
push:
77
branches: [ "main" ]
8+
paths-ignore:
9+
- 'CHANGELOG.md'
10+
- 'package.json'
11+
- 'package-lock.json
812
pull_request:
913
branches: [ "main" ]
1014
1115
permissions:
12-
contents: read
16+
contents: write
1317
pages: write
1418
id-token: write
19+
pull-requests: writ
1520
1621
concurrency:
1722
group: "pages"
@@ -22,21 +27,44 @@ jobs:
2227
runs-on: ubuntu-latest
2328
steps:
2429
- uses: actions/checkout@v6
30+
2531
- name: Use Node.js 24.x
2632
uses: actions/setup-node@v6
2733
with:
2834
node-version: 24.x
2935
cache: 'npm'
36+
3037
- run: npm ci
3138
- run: npm run lint
3239
- run: npm test
3340
- run: npm run build
41+
42+
- name: Configure Git
43+
run: |
44+
git config user.name "github-actions[bot]"
45+
git config user.email "github-actions[bot]@users.noreply.github.com
46+
3447
- name: Upload Pages artifact
3548
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
3649
uses: actions/upload-pages-artifact@v3
3750
with:
3851
path: dist/
3952

53+
- name: Extract version from package.json
54+
id: extract_version
55+
run: |
56+
VERSION=$(node -p "require('./package.json').version")
57+
echo "version=$VERSION" >> $GITHUB_OUTPUT
58+
59+
- name: Create GitHub Release
60+
uses: softprops/action-gh-release@v2
61+
with:
62+
tag_name: v${{ steps.extract_version.outputs.version }}
63+
name: Release v${{ steps.extract_version.outputs.version }}
64+
generate_release_notes: true
65+
draft: false
66+
prerelease: false
67+
4068
deploy:
4169
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
4270
needs: build

src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import RegexTester from './pages/RegexTester';
99
import UrlCodec from './pages/UrlCodec';
1010
import JwtDecoder from './pages/JwtDecoder';
1111
import ColorConverter from './pages/ColorConverter';
12+
import LoremGenerator from './pages/LoremGenerator';
13+
import HashGenerator from './pages/HashGenerator';
14+
import UuidGenerator from './pages/UuidGenerator';
15+
import TimestampConverter from './pages/TimestampConverter';
1216

1317
export default function App() {
1418
return (
@@ -23,6 +27,10 @@ export default function App() {
2327
<Route path="url" element={<UrlCodec />} />
2428
<Route path="jwt" element={<JwtDecoder />} />
2529
<Route path="color" element={<ColorConverter />} />
30+
<Route path="lorem" element={<LoremGenerator />} />
31+
<Route path="hash" element={<HashGenerator />} />
32+
<Route path="uuid" element={<UuidGenerator />} />
33+
<Route path="timestamp" element={<TimestampConverter />} />
2634
</Route>
2735
</Routes>
2836
);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import HashGenerator from '../pages/HashGenerator';
5+
6+
// Mock crypto.subtle for SHA hashes in jsdom
7+
const mockDigest = vi.fn(async (algo: string, data: ArrayBuffer) => {
8+
// Return deterministic fake hashes based on algorithm
9+
const len = algo === 'SHA-512' ? 64 : algo === 'SHA-256' ? 32 : 20;
10+
const view = new Uint8Array(data as ArrayBuffer);
11+
const buf = new ArrayBuffer(len);
12+
const out = new Uint8Array(buf);
13+
// Simple deterministic fill from input
14+
for (let i = 0; i < len; i++) {
15+
out[i] = (view[i % view.length] ?? 0) ^ (i * 7);
16+
}
17+
return buf;
18+
});
19+
20+
Object.defineProperty(globalThis, 'crypto', {
21+
value: {
22+
subtle: { digest: mockDigest },
23+
getRandomValues: (arr: Uint8Array) => {
24+
for (let i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * 256);
25+
return arr;
26+
},
27+
},
28+
writable: true,
29+
});
30+
31+
describe('HashGenerator', () => {
32+
it('renders input area', () => {
33+
render(<HashGenerator />);
34+
expect(screen.getByLabelText(/text to hash/i)).toBeInTheDocument();
35+
});
36+
37+
it('renders all algorithm cards', () => {
38+
render(<HashGenerator />);
39+
expect(screen.getByText('MD5')).toBeInTheDocument();
40+
expect(screen.getByText('SHA-1')).toBeInTheDocument();
41+
expect(screen.getByText('SHA-256')).toBeInTheDocument();
42+
expect(screen.getByText('SHA-512')).toBeInTheDocument();
43+
});
44+
45+
it('computes hashes when text is entered', async () => {
46+
const user = userEvent.setup();
47+
render(<HashGenerator />);
48+
await user.type(screen.getByLabelText(/text to hash/i), 'hello');
49+
await waitFor(() => {
50+
const md5Card = screen.getByLabelText('MD5 hash value');
51+
expect(md5Card.textContent).not.toBe('—');
52+
});
53+
});
54+
55+
it('shows empty state when input is cleared', async () => {
56+
const user = userEvent.setup();
57+
render(<HashGenerator />);
58+
await user.type(screen.getByLabelText(/text to hash/i), 'test');
59+
await user.click(screen.getByRole('button', { name: /clear/i }));
60+
await waitFor(() => {
61+
const md5Card = screen.getByLabelText('MD5 hash value');
62+
expect(md5Card.textContent).toBe('—');
63+
});
64+
});
65+
66+
it('MD5 produces a 32-char hex string', async () => {
67+
const user = userEvent.setup();
68+
render(<HashGenerator />);
69+
await user.type(screen.getByLabelText(/text to hash/i), 'test');
70+
await waitFor(() => {
71+
const md5Card = screen.getByLabelText('MD5 hash value');
72+
expect(md5Card.textContent).toMatch(/^[0-9a-f]{32}$/);
73+
});
74+
});
75+
});

src/__tests__/Layout.test.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,12 @@ describe('Layout', () => {
2424
expect(screen.getByText('UltraFormat')).toBeInTheDocument();
2525
});
2626

27-
it('renders navigation with all tool tabs', () => {
28-
renderLayout();
29-
const nav = screen.getByRole('navigation', { name: /developer tools/i });
27+
it('renders navigation with all tool tabs', async () => {
28+
const { user } = renderLayout();
29+
// Open the dropdown to reveal the nav
30+
await user.click(screen.getByRole('button', { name: /tools/i }));
31+
const nav = screen.getByRole('listbox', { name: /developer tools/i });
3032
expect(nav).toBeInTheDocument();
31-
expect(screen.getByLabelText('JSON Formatter')).toBeInTheDocument();
32-
expect(screen.getByLabelText('Diff Checker')).toBeInTheDocument();
33-
expect(screen.getByLabelText('Base64 Codec')).toBeInTheDocument();
34-
expect(screen.getByLabelText('Code Beautify')).toBeInTheDocument();
35-
expect(screen.getByLabelText('Regex Tester')).toBeInTheDocument();
3633
});
3734

3835
it('renders the rotating privacy badge', () => {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import LoremGenerator from '../pages/LoremGenerator';
5+
6+
describe('LoremGenerator', () => {
7+
it('renders style tabs', () => {
8+
render(<LoremGenerator />);
9+
expect(screen.getByRole('tab', { name: /classic/i })).toBeInTheDocument();
10+
expect(screen.getByRole('tab', { name: /hipster/i })).toBeInTheDocument();
11+
});
12+
13+
it('renders unit selector and count input', () => {
14+
render(<LoremGenerator />);
15+
expect(screen.getByLabelText(/text unit/i)).toBeInTheDocument();
16+
expect(screen.getByLabelText(/count/i)).toBeInTheDocument();
17+
});
18+
19+
it('generates classic lorem text', async () => {
20+
const user = userEvent.setup();
21+
render(<LoremGenerator />);
22+
await user.click(screen.getByRole('button', { name: /generate/i }));
23+
const output = screen.getByLabelText(/generated text output/i) as HTMLTextAreaElement;
24+
expect(output.value.length).toBeGreaterThan(0);
25+
});
26+
27+
it('generates hipster lorem text', async () => {
28+
const user = userEvent.setup();
29+
render(<LoremGenerator />);
30+
await user.click(screen.getByRole('tab', { name: /hipster/i }));
31+
await user.click(screen.getByRole('button', { name: /generate/i }));
32+
const output = screen.getByLabelText(/generated text output/i) as HTMLTextAreaElement;
33+
expect(output.value.length).toBeGreaterThan(0);
34+
});
35+
36+
it('clears output', async () => {
37+
const user = userEvent.setup();
38+
render(<LoremGenerator />);
39+
await user.click(screen.getByRole('button', { name: /generate/i }));
40+
await user.click(screen.getByRole('button', { name: /clear/i }));
41+
const output = screen.getByLabelText(/generated text output/i) as HTMLTextAreaElement;
42+
expect(output.value).toBe('');
43+
});
44+
45+
it('shows word count after generating', async () => {
46+
const user = userEvent.setup();
47+
render(<LoremGenerator />);
48+
await user.click(screen.getByRole('button', { name: /generate/i }));
49+
expect(screen.getByText(/\d+ words/i)).toBeInTheDocument();
50+
});
51+
52+
it('generates different unit types', async () => {
53+
const user = userEvent.setup();
54+
render(<LoremGenerator />);
55+
const select = screen.getByLabelText(/text unit/i);
56+
await user.selectOptions(select, 'words');
57+
await user.click(screen.getByRole('button', { name: /generate/i }));
58+
const output = screen.getByLabelText(/generated text output/i) as HTMLTextAreaElement;
59+
expect(output.value.length).toBeGreaterThan(0);
60+
});
61+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import TimestampConverter from '../pages/TimestampConverter';
5+
6+
describe('TimestampConverter', () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers({ shouldAdvanceTime: true });
9+
vi.setSystemTime(new Date('2026-03-09T12:00:00Z'));
10+
});
11+
12+
afterEach(() => {
13+
vi.useRealTimers();
14+
});
15+
16+
it('renders the live clock', () => {
17+
render(<TimestampConverter />);
18+
expect(screen.getByLabelText(/current unix timestamp/i)).toBeInTheDocument();
19+
});
20+
21+
it('renders timestamp input', () => {
22+
render(<TimestampConverter />);
23+
expect(screen.getByLabelText(/timestamp or date input/i)).toBeInTheDocument();
24+
});
25+
26+
it('shows empty state when no input', () => {
27+
render(<TimestampConverter />);
28+
expect(screen.getByText(/enter a timestamp or date above/i)).toBeInTheDocument();
29+
});
30+
31+
it('converts unix timestamp (seconds)', async () => {
32+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
33+
render(<TimestampConverter />);
34+
await user.type(screen.getByLabelText(/timestamp or date input/i), '1000000000');
35+
expect(screen.getByText('ISO 8601')).toBeInTheDocument();
36+
expect(screen.getByText('2001-09-09T01:46:40.000Z')).toBeInTheDocument();
37+
});
38+
39+
it('converts ISO date string', async () => {
40+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
41+
render(<TimestampConverter />);
42+
await user.type(screen.getByLabelText(/timestamp or date input/i), '2024-01-01T00:00:00Z');
43+
expect(screen.getByText('1704067200')).toBeInTheDocument();
44+
});
45+
46+
it('shows error for invalid input', async () => {
47+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
48+
render(<TimestampConverter />);
49+
await user.type(screen.getByLabelText(/timestamp or date input/i), 'not-a-date');
50+
expect(screen.getByText(/could not parse/i)).toBeInTheDocument();
51+
});
52+
53+
it('fills current timestamp on Now click', async () => {
54+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
55+
render(<TimestampConverter />);
56+
await user.click(screen.getByRole('button', { name: /now/i }));
57+
const input = screen.getByLabelText(/timestamp or date input/i) as HTMLInputElement;
58+
expect(input.value).toMatch(/^\d{10}$/);
59+
});
60+
61+
it('clears input', async () => {
62+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
63+
render(<TimestampConverter />);
64+
await user.type(screen.getByLabelText(/timestamp or date input/i), '1000000000');
65+
await user.click(screen.getByRole('button', { name: /clear/i }));
66+
const input = screen.getByLabelText(/timestamp or date input/i) as HTMLInputElement;
67+
expect(input.value).toBe('');
68+
});
69+
});

0 commit comments

Comments
 (0)