Skip to content

Commit a85213f

Browse files
committed
test: add component integration tests for Svelte 5 refactoring verification
Sets up @testing-library/svelte with happy-dom and vitest to test behavioral contracts before applying the stashed runes migration. - vitest.config.ts: browser resolve conditions to avoid Svelte SSR build - test/setup.ts: localStorage mock, jest-dom matchers, transition mocks - Bits.test.ts: bit pattern rendering, ArrowUp/Down keyboard, disabledBits - Digit.test.ts: uses selected option role to avoid select-options false positives - Toast.test.ts: visibility toggle and auto-hide via fake timers - NumberControls.test.ts: button disabled states and boundary text - OpacitySkeleton.test.ts: opacity-100 applied after onMount - BitHex.test.ts: core bind:integer chain — bit flip propagates to hex digits - tsconfig.json: move target into compilerOptions to fix Vite warning
1 parent a00830d commit a85213f

11 files changed

Lines changed: 703 additions & 3 deletions

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
"@eslint/js": "^10.0.1",
2727
"@github/clipboard-copy-element": "^1.3.0",
2828
"@iconify-json/bi": "^1.2.7",
29+
"@sveltejs/vite-plugin-svelte": "^7.1.2",
30+
"@testing-library/jest-dom": "^6.9.1",
31+
"@testing-library/svelte": "^5.3.1",
32+
"@testing-library/user-event": "^14.6.1",
2933
"@types/lodash-es": "^4.17.12",
3034
"@typescript-eslint/eslint-plugin": "^8.61.0",
3135
"@typescript-eslint/parser": "^8.61.0",
@@ -42,6 +46,7 @@
4246
"eslint-plugin-prettier": "^5.5.6",
4347
"eslint-plugin-svelte": "^3.19.0",
4448
"globals": "^17.6.0",
49+
"happy-dom": "^20.10.2",
4550
"husky": "^9.1.7",
4651
"lint-staged": "^17.0.7",
4752
"lodash-es": "^4.18.1",

pnpm-lock.yaml

Lines changed: 255 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/svelte';
3+
import userEvent from '@testing-library/user-event';
4+
import BitHex from '@components/blend/BitHex.svelte';
5+
6+
// BitHex is the critical integration test: it uses bind:integer with both Bits and Digits.
7+
// Flipping a bit in the Bits row must propagate through the bind: chain and update the Digits row.
8+
// This test specifically verifies the two-way binding chain is intact after refactoring.
9+
10+
describe('BitHex', () => {
11+
it('renders checkboxes (bits) and a select (hex digit)', () => {
12+
render(BitHex, { props: { integer: 0 } });
13+
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
14+
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
15+
});
16+
17+
it('hex select reflects initial integer value', () => {
18+
render(BitHex, { props: { integer: 0 } });
19+
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
20+
const hexValue = selects.reduce((acc, s, i) => acc + Number(s.value) * Math.pow(16, selects.length - 1 - i), 0);
21+
expect(hexValue).toBe(0);
22+
});
23+
24+
it('shows 3 hex selects for integer 255 with values [0, F, F]', () => {
25+
// extractBit(255) = [0,1,1,1,1,1,1,1,1] (9 bits including leading zero),
26+
// padded to 12 bits → lengthOfBits=12 → Math.ceil(12/4)=3 hex digits.
27+
// The leading-zero nibble gives a first select value of 0.
28+
render(BitHex, { props: { integer: 255 } });
29+
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
30+
expect(selects).toHaveLength(3);
31+
expect(Number(selects[0].value)).toBe(0);
32+
expect(Number(selects[1].value)).toBe(15);
33+
expect(Number(selects[2].value)).toBe(15);
34+
});
35+
36+
it('shows 12 bit cells for integer 255, first 4 unchecked', () => {
37+
// extractBit(255) = [0,1,...,1] (9 elements) → padded to 12 bits
38+
// First 4 bits (the leading-zero nibble) are 0; last 8 bits are 1.
39+
render(BitHex, { props: { integer: 255 } });
40+
const cbs = screen.getAllByRole('checkbox') as HTMLInputElement[];
41+
expect(cbs).toHaveLength(12);
42+
expect(cbs.slice(0, 4).every((cb) => !cb.checked)).toBe(true);
43+
expect(cbs.slice(4).every((cb) => cb.checked)).toBe(true);
44+
});
45+
46+
it('flipping LSB bit from 0 → 1 updates hex digit (bind: chain)', async () => {
47+
// This is the core integration test:
48+
// Bit flip → Bits.integer updates → bind:integer propagates to BitHex → Digits re-renders
49+
const user = userEvent.setup();
50+
render(BitHex, { props: { integer: 0 } });
51+
52+
const cbs = screen.getAllByRole('checkbox');
53+
const selectsBefore = screen.getAllByRole('combobox') as HTMLSelectElement[];
54+
// All hex selects start at 0
55+
expect(selectsBefore.every((s) => Number(s.value) === 0)).toBe(true);
56+
57+
// Flip the LSB
58+
await user.click(cbs[cbs.length - 1]);
59+
60+
const selectsAfter = screen.getAllByRole('combobox') as HTMLSelectElement[];
61+
// The last hex select (LSB nibble) should now be 1
62+
expect(Number(selectsAfter[selectsAfter.length - 1].value)).toBe(1);
63+
});
64+
65+
it('flipping LSB bit from 1 → 0 updates hex digit back to 0', async () => {
66+
const user = userEvent.setup();
67+
render(BitHex, { props: { integer: 1 } });
68+
69+
const cbs = screen.getAllByRole('checkbox');
70+
const lsb = cbs[cbs.length - 1];
71+
expect(lsb).toBeChecked();
72+
73+
await user.click(lsb);
74+
75+
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
76+
expect(selects.every((s) => Number(s.value) === 0)).toBe(true);
77+
});
78+
79+
it('flipping the MSB of integer 0 checks that bit and makes the hex non-zero', async () => {
80+
// Integer 0 → 4 bits, MSB at index 0 represents 2^3=8.
81+
// After flip: integer=8, hex select shows 8.
82+
const user = userEvent.setup();
83+
render(BitHex, { props: { integer: 0 } });
84+
85+
const cbs = screen.getAllByRole('checkbox');
86+
expect(cbs).toHaveLength(4);
87+
const msb = cbs[0];
88+
expect(msb).not.toBeChecked();
89+
90+
await user.click(msb);
91+
92+
// MSB is now checked
93+
expect(msb).toBeChecked();
94+
95+
// integer became 8 = 0x8; hex selects now show non-zero
96+
const selectsAfter = screen.getAllByRole('combobox') as HTMLSelectElement[];
97+
const hexValue = selectsAfter.reduce(
98+
(acc, s, i) => acc + Number(s.value) * Math.pow(16, selectsAfter.length - 1 - i),
99+
0
100+
);
101+
expect(hexValue).toBe(8);
102+
});
103+
});

test/components/parts/Bits.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/svelte';
3+
import userEvent from '@testing-library/user-event';
4+
import { flushSync } from 'svelte';
5+
import Bits from '@components/parts/Bits.svelte';
6+
7+
describe('Bits', () => {
8+
it('renders 8 bit cells for integer 32', () => {
9+
// Uses 32, not the default prop value (42), to ensure the integer prop is actually received.
10+
// extractBit(32) = [0,1,0,0,0,0,0] → toBits(32,4) pads to 8 bits.
11+
render(Bits, { props: { integer: 32 } });
12+
expect(screen.getAllByRole('checkbox')).toHaveLength(8);
13+
});
14+
15+
it('renders 4 bit cells for integer 0 (default multipleOf=4)', () => {
16+
render(Bits, { props: { integer: 0 } });
17+
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
18+
});
19+
20+
it('shows correct bit pattern for integer 32 (0b00100000)', () => {
21+
// Uses 32, not the default prop value (42), to ensure the integer prop is actually received.
22+
// extractBit(32) = [0,1,0,0,0,0,0] → padded to [0,0,1,0,0,0,0,0].
23+
render(Bits, { props: { integer: 32 } });
24+
const cbs = screen.getAllByRole('checkbox') as HTMLInputElement[];
25+
expect(cbs.map((cb) => cb.checked)).toEqual([
26+
false, false, true, false, false, false, false, false,
27+
]);
28+
});
29+
30+
it('shows integer 15 as 8 bits with leading zero (0b00001111)', () => {
31+
// extractBit always adds a leading 0 from its base case, so 15 renders as
32+
// [0,1,1,1,1] → padded to 8 bits: [0,0,0,0,1,1,1,1]
33+
render(Bits, { props: { integer: 15 } });
34+
const cbs = screen.getAllByRole('checkbox') as HTMLInputElement[];
35+
expect(cbs).toHaveLength(8);
36+
expect(cbs.map((cb) => cb.checked)).toEqual([
37+
false, false, false, false, true, true, true, true,
38+
]);
39+
});
40+
41+
it('shows all unchecked for integer 0', () => {
42+
render(Bits, { props: { integer: 0 } });
43+
const cbs = screen.getAllByRole('checkbox') as HTMLInputElement[];
44+
expect(cbs.every((cb) => !cb.checked)).toBe(true);
45+
});
46+
47+
it('ArrowUp on a bit sets it to 1', async () => {
48+
const user = userEvent.setup();
49+
render(Bits, { props: { integer: 0 } });
50+
const cbs = screen.getAllByRole('checkbox');
51+
const lsb = cbs[cbs.length - 1] as HTMLInputElement;
52+
lsb.focus();
53+
await user.keyboard('{ArrowUp}');
54+
flushSync();
55+
expect(lsb).toBeChecked();
56+
});
57+
58+
it('ArrowDown on a bit sets it to 0', async () => {
59+
const user = userEvent.setup();
60+
render(Bits, { props: { integer: 1 } });
61+
const cbs = screen.getAllByRole('checkbox');
62+
const lsb = cbs[cbs.length - 1] as HTMLInputElement;
63+
lsb.focus();
64+
await user.keyboard('{ArrowDown}');
65+
flushSync();
66+
expect(lsb).not.toBeChecked();
67+
});
68+
69+
it('disabledBits 0xFFFFFFFF disables all checkboxes', () => {
70+
render(Bits, { props: { integer: 42, disabledBits: 0xffffffff } });
71+
const cbs = screen.getAllByRole('checkbox');
72+
for (const cb of cbs) {
73+
expect(cb).toBeDisabled();
74+
}
75+
});
76+
77+
it('disabledBits 0 leaves all checkboxes enabled', () => {
78+
render(Bits, { props: { integer: 42, disabledBits: 0 } });
79+
const cbs = screen.getAllByRole('checkbox');
80+
for (const cb of cbs) {
81+
expect(cb).not.toBeDisabled();
82+
}
83+
});
84+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/svelte';
3+
import Digit from '@components/parts/Digit.svelte';
4+
5+
// Note on test strategy: Digit renders an overlay <div> with the current character AND a
6+
// <select> with ALL possible options for the base. Querying by text alone (getAllByText)
7+
// is a false positive because every option character is always in the DOM regardless of
8+
// which digit is selected. Tests here use getByRole('option', { selected: true }) to
9+
// assert the SELECTED option, which is the one that actually reflects the digit prop.
10+
11+
describe('Digit', () => {
12+
it('selected option shows decimal digit 5 as "5"', () => {
13+
render(Digit, { props: { digit: 5, base: 10, position: 0 } });
14+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('5');
15+
});
16+
17+
it('selected option shows hex digit 10 as "A"', () => {
18+
render(Digit, { props: { digit: 10, base: 16, position: 0 } });
19+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('A');
20+
});
21+
22+
it('selected option shows hex digit 15 as "F"', () => {
23+
render(Digit, { props: { digit: 15, base: 16, position: 0 } });
24+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('F');
25+
});
26+
27+
it('selected option shows hex digit 0 as "0"', () => {
28+
render(Digit, { props: { digit: 0, base: 16, position: 0 } });
29+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('0');
30+
});
31+
32+
it('select element value matches digit prop', () => {
33+
render(Digit, { props: { digit: 7, base: 16, position: 0 } });
34+
const select = screen.getByRole('combobox') as HTMLSelectElement;
35+
expect(Number(select.value)).toBe(7);
36+
});
37+
38+
it('select has base number of options', () => {
39+
render(Digit, { props: { digit: 0, base: 16, position: 0 } });
40+
const select = screen.getByRole('combobox') as HTMLSelectElement;
41+
expect(select.options.length).toBe(16);
42+
});
43+
44+
it('select has data-position attribute matching position prop', () => {
45+
render(Digit, { props: { digit: 0, base: 16, position: 3 } });
46+
const select = screen.getByRole('combobox');
47+
expect(select).toHaveAttribute('data-position', '3');
48+
});
49+
50+
it('selected option shows binary digit 0 as "0"', () => {
51+
render(Digit, { props: { digit: 0, base: 2, position: 0 } });
52+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('0');
53+
});
54+
55+
it('selected option shows binary digit 1 as "1"', () => {
56+
render(Digit, { props: { digit: 1, base: 2, position: 0 } });
57+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('1');
58+
});
59+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/svelte';
3+
import NumberControls from '@components/parts/NumberControls.svelte';
4+
5+
describe('NumberControls', () => {
6+
it('renders 4 buttons', () => {
7+
render(NumberControls, { props: { integer: 50, min: 0, max: 100 } });
8+
expect(screen.getAllByRole('button')).toHaveLength(4);
9+
});
10+
11+
it('setToMax button is disabled when integer equals max', () => {
12+
render(NumberControls, { props: { integer: 100, min: 0, max: 100 } });
13+
const [maxBtn] = screen.getAllByRole('button');
14+
expect(maxBtn).toBeDisabled();
15+
expect(maxBtn).toHaveTextContent('max');
16+
});
17+
18+
it('setToMin button is disabled when integer equals min', () => {
19+
render(NumberControls, { props: { integer: 0, min: 0, max: 100 } });
20+
const [, minBtn] = screen.getAllByRole('button');
21+
expect(minBtn).toBeDisabled();
22+
expect(minBtn).toHaveTextContent('min');
23+
});
24+
25+
it('both action buttons enabled in normal state', () => {
26+
render(NumberControls, { props: { integer: 50, min: 0, max: 100 } });
27+
const [maxBtn, minBtn] = screen.getAllByRole('button');
28+
expect(maxBtn).not.toBeDisabled();
29+
expect(minBtn).not.toBeDisabled();
30+
});
31+
32+
it('increment button shows "+1" when not at max', () => {
33+
render(NumberControls, { props: { integer: 50, min: 0, max: 100 } });
34+
const buttons = screen.getAllByRole('button');
35+
expect(buttons[2]).toHaveTextContent('+1');
36+
});
37+
38+
it('increment button shows "min" when at max', () => {
39+
render(NumberControls, { props: { integer: 100, min: 0, max: 100 } });
40+
const buttons = screen.getAllByRole('button');
41+
expect(buttons[2]).toHaveTextContent('min');
42+
});
43+
44+
it('decrement button shows "-1" when not at min', () => {
45+
render(NumberControls, { props: { integer: 50, min: 0, max: 100 } });
46+
const buttons = screen.getAllByRole('button');
47+
expect(buttons[3]).toHaveTextContent('-1');
48+
});
49+
50+
it('decrement button shows "max" when at min', () => {
51+
render(NumberControls, { props: { integer: 0, min: 0, max: 100 } });
52+
const buttons = screen.getAllByRole('button');
53+
expect(buttons[3]).toHaveTextContent('max');
54+
});
55+
56+
it('displays formatted max value', () => {
57+
render(NumberControls, { props: { integer: 50, min: 0, max: 1000000 } });
58+
expect(screen.getByText('1,000,000')).toBeInTheDocument();
59+
});
60+
61+
it('displays formatted min value', () => {
62+
render(NumberControls, { props: { integer: 50, min: 1000, max: 9999 } });
63+
expect(screen.getByText('1,000')).toBeInTheDocument();
64+
});
65+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from '@testing-library/svelte';
3+
import OpacitySkeleton from '@components/parts/OpacitySkeleton.svelte';
4+
5+
describe('OpacitySkeleton', () => {
6+
it('renders a wrapper div', () => {
7+
const { container } = render(OpacitySkeleton);
8+
expect(container.firstElementChild).toBeTruthy();
9+
});
10+
11+
it('applies opacity-100 after mount (onMount fires)', () => {
12+
const { container } = render(OpacitySkeleton);
13+
const wrapper = container.firstElementChild as HTMLElement;
14+
// Before stash: onMount sets mounted=true which adds opacity-100
15+
// After stash: $effect replaces onMount — same behavior expected
16+
expect(wrapper).toHaveClass('opacity-100');
17+
});
18+
19+
it('has opacity-0 class initially (before transition)', () => {
20+
// The div has opacity-0 as a static class in addition to the conditional opacity-100
21+
const { container } = render(OpacitySkeleton);
22+
const wrapper = container.firstElementChild as HTMLElement;
23+
expect(wrapper).toHaveClass('opacity-0');
24+
});
25+
});

0 commit comments

Comments
 (0)