diff --git a/src/__tests__/ui.spec.tsx b/src/__tests__/ui.spec.tsx index 334e6b0..a285cd0 100644 --- a/src/__tests__/ui.spec.tsx +++ b/src/__tests__/ui.spec.tsx @@ -13,18 +13,67 @@ const renderApp = () => , ); +const ALGORITHM_NAMES = ['バブルソート', '選択ソート', 'マージソート', 'クイックソート']; +const BAR_REGION_LABELS = [ + 'バブルソートのバー表示', + '選択ソートのバー表示', + 'マージソートのバー表示', + 'クイックソートのバー表示', +]; + function getBars(container: HTMLElement) { return container.querySelectorAll('.bar'); } -function getAllSliders() { - return screen.getAllByRole('slider'); +/** 棒に表示している数字を左から順に返す */ +function barLabels(container: HTMLElement) { + return Array.from(getBars(container), (bar) => Number(bar.dataset.label)); +} + +/** 選択チップのアルゴリズム名を、画面上の並び順で返す */ +function chipOrder() { + const checkboxes = screen.getAllByRole('checkbox'); + const position = (name: string) => checkboxes.indexOf(screen.getByRole('checkbox', { name })); + return [...ALGORITHM_NAMES].sort((a, b) => position(a) - position(b)); +} + +/** 要素がないときも表示していないとみなす */ +function isDisplayed(container: HTMLElement, selector: string) { + return Array.from(container.querySelectorAll(selector)).some( + (element) => getComputedStyle(element).display !== 'none', + ); +} + +/** 棒の並びが条件を満たすまでシャッフルを繰り返す */ +function shuffleUntil(condition: (labels: number[]) => boolean, maxShuffles = 5000) { + const region = screen.getByLabelText('バブルソートのバー表示'); + const shuffle = screen.getByRole('button', { name: 'シャッフル' }); + for (let count = 0; count < maxShuffles && !condition(barLabels(region)); count++) { + fireEvent.click(shuffle); + } + expect(condition(barLabels(region))).toBe(true); +} + +/** フェイクタイマーで速度 1.00 の再生を 1 ステップずつ進め、条件を満たしたところで止める */ +function playUntil(condition: () => boolean, maxSteps = 200) { + for (let step = 0; step < maxSteps && !condition(); step++) { + act(() => { + vi.advanceTimersByTime(computeInterval(1)); + }); + } + expect(condition()).toBe(true); } describe('Algorithm visualizer UI specification', () => { beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); + // 初期配列とシャッフルに使う乱数を固定し、毎回同じ並びで再現できるようにする(Park–Miller の線形合同法) + let seed = 1; + vi.spyOn(Math, 'random').mockImplementation(() => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }); }); afterEach(() => { @@ -34,8 +83,7 @@ describe('Algorithm visualizer UI specification', () => { it('toggles panels from the selector and can recover from hiding every algorithm', async () => { renderApp(); const user = userEvent.setup(); - const names = ['バブルソート', '選択ソート', 'マージソート', 'クイックソート']; - for (const name of names) { + for (const name of ALGORITHM_NAMES) { expect(screen.getByRole('checkbox', { name })).toBeChecked(); await user.click(screen.getByRole('checkbox', { name })); expect(screen.queryByRole('region', { name })).not.toBeInTheDocument(); @@ -59,7 +107,7 @@ describe('Algorithm visualizer UI specification', () => { const initialBars = screen.getByLabelText('バブルソートのバー表示').innerHTML; expect(selectAll).toBeDisabled(); - await user.click(screen.getAllByRole('checkbox')[0]); + await user.click(screen.getByRole('checkbox', { name: 'バブルソート' })); expect(selectAll).toBeEnabled(); await user.click(selectAll); expect(screen.getAllByRole('heading', { level: 2 }).map((el) => el.textContent)).toEqual( @@ -116,9 +164,7 @@ describe('Algorithm visualizer UI specification', () => { await user.click(screen.getByRole('checkbox', { name: '選択ソート' })); screen.getByRole('button', { name: 'バブルソートの表示順を変更' }).focus(); await user.keyboard(' {ArrowRight} '); - expect( - screen.getAllByRole('checkbox').map((el) => el.nextElementSibling?.textContent), - ).toEqual(['選択ソート', 'バブルソート', 'マージソート', 'クイックソート']); + expect(chipOrder()).toEqual(['選択ソート', 'バブルソート', 'マージソート', 'クイックソート']); expect(screen.getByRole('checkbox', { name: '選択ソート' })).not.toBeChecked(); await user.click(screen.getByRole('checkbox', { name: '選択ソート' })); expect(screen.getAllByRole('heading', { level: 2 }).map((el) => el.textContent)).toEqual([ @@ -207,7 +253,7 @@ describe('Algorithm visualizer UI specification', () => { it('renders the size control slider with sane defaults and range (ARIA)', () => { renderApp(); - const [sizeSlider] = getAllSliders(); + const sizeSlider = screen.getByRole('slider', { name: '本数:' }); // Mantine のスライダーは ARIA 属性で検証する expect(sizeSlider).toHaveAttribute('aria-valuemin', '5'); @@ -223,7 +269,7 @@ describe('Algorithm visualizer UI specification', () => { it('renders the speed control and keeps two-decimal display', async () => { renderApp(); - const [, speedSlider] = getAllSliders(); + const speedSlider = screen.getByRole('slider', { name: 'アニメ速度:' }); expect(speedSlider).toHaveAttribute('aria-valuemin', '0.2'); expect(speedSlider).toHaveAttribute('aria-valuemax', '10'); @@ -279,27 +325,36 @@ describe('Algorithm visualizer UI specification', () => { }); }); - it('shows every panel and its legend and step counter', () => { + it.each([ + { name: 'バブルソート', texts: ['入れ替え/比較', 'ソート完了'] }, + { name: '選択ソート', texts: ['入れ替え/比較', '最小値候補', 'ソート完了'] }, + { + name: 'マージソート', + texts: [ + '比べて並べる', + '範囲と分け目', + 'ソート完了', + '1個ずつに分けて、小さい順にまとめます。', + ], + }, + { + name: 'クイックソート', + texts: [ + '入れ替え/比較', + '左の交換候補(枠)', + '右の交換候補(枠)', + 'ピボット', + '境界(グループ分け)', + 'ピボット高(横線)', + 'ソート完了', + ], + }, + ])('shows the $name panel with a zero step counter and its legend', ({ name, texts }) => { renderApp(); + const panel = within(screen.getByRole('region', { name })); - // 各パネルに開始前のステップ数が表示される - const stepZeros = screen.getAllByText(/ステップ:\s*0/); - expect(stepZeros).toHaveLength(4); - - // 凡例テキストが表示されている(パネルに強く依存しない) - expect(screen.getAllByText('入れ替え/比較').length).toBeGreaterThan(0); - expect(screen.getAllByText(/ソート完了/).length).toBeGreaterThan(0); - expect(screen.getByText('ピボット')).toBeInTheDocument(); - expect(screen.getByText(/境界(グループ分け)/)).toBeInTheDocument(); - expect(screen.getByText(/ピボット高(横線)/)).toBeInTheDocument(); - expect(screen.getByText('最小値候補')).toBeInTheDocument(); - const mergeLegend = within(screen.getByRole('region', { name: 'マージソート' })); - expect(mergeLegend.getByText('比べて並べる')).toBeInTheDocument(); - expect(mergeLegend.getByText('範囲と分け目')).toBeInTheDocument(); - expect(mergeLegend.getByText('1個ずつに分けて、小さい順にまとめます。')).toBeInTheDocument(); - const quickLegend = within(screen.getByRole('region', { name: 'クイックソート' })); - expect(quickLegend.getByText('左の交換候補(枠)')).toBeInTheDocument(); - expect(quickLegend.getByText('右の交換候補(枠)')).toBeInTheDocument(); + expect(panel.getByText('ステップ: 0')).toBeInTheDocument(); + for (const text of texts) expect(panel.getByText(text)).toBeInTheDocument(); }); it('increments the bar count immediately when using the size stepper controls', async () => { @@ -308,7 +363,7 @@ describe('Algorithm visualizer UI specification', () => { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: '本数を1増やす' })); - const [sizeSlider] = getAllSliders(); + const sizeSlider = screen.getByRole('slider', { name: '本数:' }); await waitFor(() => { expect(sizeSlider).toHaveAttribute('aria-valuenow', '21'); }); @@ -337,7 +392,7 @@ describe('Algorithm visualizer UI specification', () => { renderApp(); const user = userEvent.setup(); - const [sizeSlider] = getAllSliders(); + const sizeSlider = screen.getByRole('slider', { name: '本数:' }); // Mantine のスライダーはキーボードで値変更できる(step=1) sizeSlider.focus(); @@ -360,7 +415,7 @@ describe('Algorithm visualizer UI specification', () => { renderApp(); const user = userEvent.setup(); - const [, speedSlider] = getAllSliders(); + const speedSlider = screen.getByRole('slider', { name: 'アニメ速度:' }); speedSlider.focus(); // 1.00 -> 1.25(5回 +0.05) @@ -369,43 +424,30 @@ describe('Algorithm visualizer UI specification', () => { expect(screen.getByText(/アニメ速度:\s*1\.25/)).toBeInTheDocument(); }); - it('shuffles with the current bar count and keeps every panel synchronized', async () => { + it('shuffles into a new arrangement of the current bar count shared by every panel', async () => { renderApp(); - const user = userEvent.setup(); - const randomSpy = vi.spyOn(Math, 'random'); + await user.click(screen.getByRole('button', { name: '本数を1増やす' })); + const before = barLabels(screen.getByLabelText('バブルソートのバー表示')); await user.click(screen.getByRole('button', { name: 'シャッフル' })); - expect(randomSpy.mock.calls.length).toBeGreaterThan(0); - const bubbleRegion = screen.getByLabelText('バブルソートのバー表示'); - const selectionRegion = screen.getByLabelText('選択ソートのバー表示'); - const mergeRegion = screen.getByLabelText('マージソートのバー表示'); - const quickRegion = screen.getByLabelText('クイックソートのバー表示'); - - const bubbleHeights = Array.from(getBars(bubbleRegion), (bar) => bar.style.height); - const selectionHeights = Array.from(getBars(selectionRegion), (bar) => bar.style.height); - const mergeHeights = Array.from(getBars(mergeRegion), (bar) => bar.style.height); - const quickHeights = Array.from(getBars(quickRegion), (bar) => bar.style.height); - expect(bubbleHeights).toEqual(quickHeights); - expect(selectionHeights).toEqual(quickHeights); - expect(mergeHeights).toEqual(quickHeights); + const after = barLabels(screen.getByLabelText('バブルソートのバー表示')); + expect(after).not.toEqual(before); + // 現在の本数 21 のまま、1〜21 を 1 回ずつ並べ直す + expect([...after].sort((a, b) => a - b)).toEqual(Array.from({ length: 21 }, (_, i) => i + 1)); + for (const label of BAR_REGION_LABELS) { + expect(barLabels(screen.getByLabelText(label))).toEqual(after); + } }); it('hides quick-sort overlay elements until a processing range exists', () => { renderApp(); const quickRegion = screen.getByLabelText('クイックソートのバー表示'); - const boundary = quickRegion.querySelector('.boundary'); - const subrange = quickRegion.querySelector('.subrange'); - const pivotLine = quickRegion.querySelector('.pivot-hline'); - const zones = quickRegion.querySelectorAll('.zone'); - - expect(boundary?.style.display).toBe('none'); - expect(subrange?.style.display).toBe('none'); - expect(pivotLine?.style.display).toBe('none'); - expect(zones[0]?.style.display).toBe('none'); - expect(zones[1]?.style.display).toBe('none'); + for (const selector of ['.boundary', '.subrange', '.pivot-hline', '.zone']) { + expect(isDisplayed(quickRegion, selector)).toBe(false); + } }); it('shows the merge range and split line during playback', () => { @@ -413,86 +455,69 @@ describe('Algorithm visualizer UI specification', () => { try { renderApp(); const region = screen.getByLabelText('マージソートのバー表示'); - const range = region.querySelector('.subrange'); - const split = region.querySelector('.boundary'); - expect(range?.style.display).toBe('none'); - expect(split?.style.display).toBe('none'); + expect(isDisplayed(region, '.subrange')).toBe(false); + expect(isDisplayed(region, '.boundary')).toBe(false); fireEvent.click(screen.getByRole('button', { name: /再生/ })); act(() => { vi.advanceTimersByTime(2000); }); - expect(range?.style.display).toBe('block'); - expect(split?.style.display).toBe('block'); + expect(isDisplayed(region, '.subrange')).toBe(true); + expect(isDisplayed(region, '.boundary')).toBe(true); } finally { vi.useRealTimers(); } }); it('shows which side contributes each value to the merge result before writing it back', () => { - const random = vi.spyOn(Math, 'random').mockReturnValue(0.999); vi.useFakeTimers(); try { renderApp(); for (let i = 0; i < 15; i++) { fireEvent.click(screen.getByRole('button', { name: '本数を1減らす' })); } - const merge = within(screen.getByRole('region', { name: 'マージソート' })); + // 5 本を昇順に並べ、最初のマージで左の 1 と右の 2 を比べる場面を作る + shuffleUntil((labels) => labels.join() === '1,2,3,4,5'); + const region = screen.getByRole('region', { name: 'マージソート' }); + const merge = within(region); fireEvent.click(screen.getByRole('button', { name: '再生' })); - const interval = computeInterval(1); - act(() => { - vi.advanceTimersByTime(interval * 9); - }); - expect(merge.getByText('左の残り:').parentElement).toHaveTextContent('[1]'); - expect(merge.getByText('右の残り:').parentElement).toHaveTextContent('[2]'); - expect(merge.getByText('結果:').parentElement).toHaveTextContent('[]'); + playUntil(() => merge.queryByText('左の残り:') !== null); + expect(region).toHaveTextContent('左の残り:[1]'); + expect(region).toHaveTextContent('右の残り:[2]'); + expect(region).toHaveTextContent('結果:[]'); - act(() => { - vi.advanceTimersByTime(interval * 2); - }); - expect(merge.getByText('左の1を結果に入れました。')).toBeInTheDocument(); - expect(merge.getByText('左の残り:').parentElement).toHaveTextContent('[]'); - expect(merge.getByText('右の残り:').parentElement).toHaveTextContent('[2]'); - expect(merge.getByText('結果:').parentElement).toHaveTextContent('[1]'); + playUntil(() => merge.queryByText('左の1を結果に入れました。') !== null); + expect(region).toHaveTextContent('左の残り:[]'); + expect(region).toHaveTextContent('右の残り:[2]'); + expect(region).toHaveTextContent('結果:[1]'); - act(() => { - vi.advanceTimersByTime(interval * 2); - }); - expect(merge.getByText('結果に合わせて棒を並べ直しています。')).toBeInTheDocument(); - expect(merge.getByText('結果:').parentElement).toHaveTextContent('[1, 2]'); + playUntil(() => merge.queryByText('結果に合わせて棒を並べ直しています。') !== null); + expect(region).toHaveTextContent('結果:[1, 2]'); } finally { - random.mockRestore(); vi.useRealTimers(); } }); it('keeps bar heights on the original scale while merge writes temporarily duplicate values', () => { - const random = vi.spyOn(Math, 'random').mockReturnValue(0); vi.useFakeTimers(); try { renderApp(); for (let i = 0; i < 15; i++) { fireEvent.click(screen.getByRole('button', { name: '本数を1減らす' })); } + // 最大値 5 を右端以外に置く。書き戻しの途中で 5 が一時的に消える + shuffleUntil((labels) => labels[labels.length - 1] !== 5); const region = screen.getByLabelText('マージソートのバー表示'); - const bars = () => Array.from(getBars(region)); - expect(bars().map((bar) => bar.dataset.label)).toEqual(['2', '3', '4', '5', '1']); fireEvent.click(screen.getByRole('button', { name: '再生' })); - let labels: (string | undefined)[] = []; - for (let tick = 0; tick < 40; tick++) { - act(() => { - vi.advanceTimersByTime(computeInterval(1)); - }); - labels = bars().map((bar) => bar.dataset.label); - if (!labels.includes('5')) break; - } + playUntil(() => !barLabels(region).includes(5)); - expect(labels).not.toContain('5'); - expect(Math.max(...bars().map((bar) => parseFloat(bar.style.height)))).toBe(80); + // 高さの基準は、表示中の最大値ではなく初期配列の最大値 5 のまま + for (const bar of getBars(region)) { + expect(parseFloat(bar.style.height)).toBeCloseTo((Number(bar.dataset.label) / 5) * 100); + } } finally { - random.mockRestore(); vi.useRealTimers(); } }); @@ -505,14 +530,14 @@ describe('Algorithm visualizer UI specification', () => { }); it('keeps comparison outlines while paused and removes them when bubble sort finishes', () => { - // 昇順の初期配列に固定し、最後の操作が比較になるケースを再現する。 - const random = vi.spyOn(Math, 'random').mockReturnValue(0.999); vi.useFakeTimers(); try { renderApp(); for (let i = 0; i < 15; i++) { fireEvent.click(screen.getByRole('button', { name: '本数を1減らす' })); } + // 昇順に並べ、最後の操作が比較になるケースを再現する。 + shuffleUntil((labels) => labels.join() === '1,2,3,4,5'); const region = screen.getByLabelText('バブルソートのバー表示'); fireEvent.click(screen.getByRole('button', { name: '再生' })); act(() => { @@ -526,15 +551,11 @@ describe('Algorithm visualizer UI specification', () => { expect(region.querySelectorAll('.compare')).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: '再生' })); - act(() => { - vi.advanceTimersByTime(9000); - }); - expect(region.querySelectorAll('.sorted')).toHaveLength(5); + playUntil(() => region.querySelectorAll('.sorted').length === 5); expect(region.querySelectorAll('.compare')).toHaveLength(0); // 他のアルゴリズムが再生中でも、完了したパネルの比較枠は消える。 expect(screen.getByRole('button', { name: '一時停止' })).toBeEnabled(); } finally { - random.mockRestore(); vi.useRealTimers(); } }); diff --git a/src/__tests__/visualizer.spec.ts b/src/__tests__/visualizer.spec.ts index 1b43d0a..0169114 100644 --- a/src/__tests__/visualizer.spec.ts +++ b/src/__tests__/visualizer.spec.ts @@ -25,6 +25,33 @@ function applySwaps(initial: number[], steps: Step[]): number[] { return arr; } +function isStep(t: T) { + return (step: Step): step is Extract => step.t === t; +} + +// 比較・交換の 2 本は画面上で区別しないため、順序を除いて比べる +function pairOf(i: number, j: number): [number, number] { + return i < j ? [i, j] : [j, i]; +} + +function ascending(values: number[]): number[] { + return [...values].sort((a, b) => a - b); +} + +function positions(from: number, to: number): number[] { + return Array.from({ length: to - from }, (_, offset) => from + offset); +} + +/** 各ステップと、そのステップを適用した後の棒の並びを組にする */ +function withBars(initial: number[], steps: Step[]) { + const bars = initial.slice(); + return steps.map((step) => { + if (step.t === 'swap') [bars[step.i], bars[step.j]] = [bars[step.j], bars[step.i]]; + if (step.t === 'write') bars[step.i] = step.value; + return { step, bars: bars.slice() }; + }); +} + describe('visualizer logic specification', () => { it('produces bubble sort steps that compare adjacent bars and sort the sequence', () => { const original = [5, 3, 4, 1, 2]; @@ -33,249 +60,362 @@ describe('visualizer logic specification', () => { expect(sorted).toEqual([1, 2, 3, 4, 5]); - const comparisons = steps.filter((step) => step.t === 'compare'); + const comparisons = steps.filter(isStep('compare')); const n = original.length; expect(comparisons).toHaveLength((n * (n - 1)) / 2); comparisons.forEach((step) => { - if (step.t === 'compare') { - expect(step.j - step.i).toBe(1); - } + expect(Math.abs(step.j - step.i)).toBe(1); }); steps.forEach((step, index) => { if (step.t === 'swap') { - expect(index).toBeGreaterThan(0); const prev = steps[index - 1]; - expect(prev.t).toBe('compare'); - if (prev.t === 'compare') { - expect(prev.i).toBe(step.i); - expect(prev.j).toBe(step.j); + expect(prev?.t).toBe('compare'); + if (prev?.t === 'compare') { + expect(pairOf(prev.i, prev.j)).toEqual(pairOf(step.i, step.j)); } } }); }); - it('produces selection sort steps that track the minimum candidate and swap at most once per pass', () => { - const original = [5, 3, 4, 1, 2]; - const steps = buildSelectionSteps(original); - const sorted = applySwaps(original, steps); + it.each([ + // 各パスの最小値 1, 2, 3, 4 を、パスの先頭 0〜3 番目へ 1 回ずつ移す + { + input: [5, 3, 4, 1, 2], + swaps: [ + [0, 3], + [1, 4], + [2, 4], + [3, 4], + ], + }, + // 最小値がすでにパスの先頭にある最初のパスでは入れ替えない + { input: [1, 3, 2], swaps: [[1, 2]] }, + ])( + 'swaps the minimum of each selection sort pass into the front of the pass only when it is elsewhere ($input)', + ({ input, swaps }) => { + const steps = buildSelectionSteps(input); - expect(sorted).toEqual([1, 2, 3, 4, 5]); + expect(applySwaps(input, steps)).toEqual(ascending(input)); + expect(steps.filter(isStep('swap')).map((step) => pairOf(step.i, step.j))).toEqual(swaps); + }, + ); - // 比較回数は必ず n(n-1)/2 回(選択ソートは入力によらず全比較する) - const comparisons = steps.filter((step) => step.t === 'compare'); + it('compares every unsorted bar with the minimum candidate in each selection sort pass', () => { + const original = [5, 3, 4, 1, 2]; const n = original.length; - expect(comparisons).toHaveLength((n * (n - 1)) / 2); + const steps = buildSelectionSteps(original); + + // パスは clearMarks で終わる + const passes: Step[][] = [[]]; + for (const step of steps) { + passes[passes.length - 1].push(step); + if (step.t === 'clearMarks') passes.push([]); + } + expect(passes.pop()).toEqual([]); + expect(passes).toHaveLength(n - 1); - // 交換はパスごとに最大 1 回なので合計 n-1 回以下 - const swaps = steps.filter((step) => step.t === 'swap'); - expect(swaps.length).toBeLessThanOrEqual(n - 1); - - // compare は常に「未ソート領域の要素 vs 現在の最小値候補(markL)」の形になっている - let currentMin: number | null = null; - steps.forEach((step) => { - switch (step.t) { - case 'markL': - currentMin = step.i; - break; - case 'compare': - expect(currentMin).not.toBeNull(); - expect(step.j).toBe(currentMin); - break; - case 'clearMarks': - currentMin = null; - break; - default: - break; + passes.forEach((pass, start) => { + // パスの先頭を最初の最小値候補にする + expect(pass[0]).toEqual({ t: 'markL', i: start }); + let candidate = start; + const compared: number[] = []; + for (const step of pass) { + if (step.t === 'markL') candidate = step.i; + if (step.t === 'compare') { + expect([step.i, step.j]).toContain(candidate); + compared.push(step.i === candidate ? step.j : step.i); + } } + // 選択ソートは入力によらず、未ソートの残りの棒すべてを 1 回ずつ候補と比べる + expect(ascending(compared)).toEqual(positions(start + 1, n)); }); - - // 各パスは markL で始まり clearMarks で終わる - expect(steps[0].t).toBe('markL'); - expect(steps[steps.length - 1].t).toBe('clearMarks'); }); - it('merges in ascending stable order by taking from the smaller remaining head', () => { - const original = [4, 2, 4, 1, 2]; - const values = original.slice(); - const ids = original.map((_, index) => index + 1); - const steps = buildMergeSteps(original); - let activeRange: { lo: number; hi: number } | null = null; - let splitIndex: number | null = null; - let merge: { + describe('merge sort steps', () => { + const input = [4, 2, 4, 1, 2]; + + type Merge = { + lo: number; + hi: number; + split: number; left: number[]; right: number[]; - leftIndex: number; - rightIndex: number; - output: number[]; - } | null = null; - let writing = false; + /** mergeStart 時点の棒の並び */ + bars: number[]; + /** mergeStart の後、範囲の表示を消すまでのステップ */ + steps: Step[]; + }; - steps.forEach((step, index) => { - switch (step.t) { - case 'range': - activeRange = step.lo == null || step.hi == null ? null : { lo: step.lo, hi: step.hi }; - if (!activeRange) merge = null; - writing = false; - break; - case 'boundary': - expect(activeRange).not.toBeNull(); - expect(step.k).toBeGreaterThan(activeRange!.lo); - expect(step.k).toBeLessThanOrEqual(activeRange!.hi); - splitIndex = step.k; - break; - case 'mergeStart': - expect(activeRange).not.toBeNull(); - expect(step.left.length + step.right.length).toBe(activeRange!.hi - activeRange!.lo + 1); - merge = { left: step.left, right: step.right, leftIndex: 0, rightIndex: 0, output: [] }; - break; - case 'compare': - expect(activeRange).not.toBeNull(); - expect(merge).not.toBeNull(); - expect(writing).toBe(false); - expect(step.i).toBe(activeRange!.lo + merge!.leftIndex); - expect(step.j).toBe(splitIndex! + merge!.rightIndex); - break; - case 'mergeTake': { - expect(merge).not.toBeNull(); - expect(writing).toBe(false); - const left = merge!.left[merge!.leftIndex]; - const right = merge!.right[merge!.rightIndex]; - if (left != null && right != null) { - expect(steps[index - 1].t).toBe('compare'); - expect(step.side).toBe(left <= right ? 'left' : 'right'); - } - const expected = step.side === 'left' ? left : right; - expect(step.value).toBe(expected); - expect(step.source).toBe( - step.side === 'left' - ? activeRange!.lo + merge!.leftIndex - : splitIndex! + merge!.rightIndex, - ); - merge!.output.push(step.value); - if (step.side === 'left') merge!.leftIndex++; - else merge!.rightIndex++; - break; + /** ステップ列をマージごとに区切り、マージの開始時に表示している範囲と分け目を添える */ + function traceMerges(values: number[]): Merge[] { + const merges: Merge[] = []; + let range: { lo: number; hi: number } | null = null; + let split: number | null = null; + let current: Merge | null = null; + for (const { step, bars } of withBars(values, buildMergeSteps(values))) { + if (step.t === 'range') { + range = step.lo == null || step.hi == null ? null : { lo: step.lo, hi: step.hi }; + current = null; + } else if (step.t === 'boundary') { + split = step.k; + } else if (step.t === 'mergeStart') { + expect(range).not.toBeNull(); + expect(split).not.toBeNull(); + current = { + ...range!, + split: split!, + left: step.left, + right: step.right, + bars, + steps: [], + }; + merges.push(current); + } else { + // 比較・結果への移動・書き戻しは、範囲を表示しているマージの中でだけ行う + expect(current, `${step.t} outside a merge`).not.toBeNull(); + current?.steps.push(step); } - case 'write': - expect(activeRange).not.toBeNull(); - expect(merge).not.toBeNull(); - expect(merge!.output).toHaveLength(merge!.left.length + merge!.right.length); - expect(step.i).toBeGreaterThanOrEqual(activeRange!.lo); - expect(step.i).toBeLessThanOrEqual(activeRange!.hi); - expect(step.value).toBe(merge!.output[step.i - activeRange!.lo]); - expect(step.value).toBe(original[step.id - 1]); - values[step.i] = step.value; - ids[step.i] = step.id; - writing = true; - break; - default: - break; } - }); + return merges; + } - expect(original).toEqual([4, 2, 4, 1, 2]); - expect(values).toEqual([1, 2, 2, 4, 4]); - expect(ids).toEqual([4, 2, 5, 1, 3]); - expect(activeRange).toBeNull(); - expect(steps.some((step) => step.t === 'compare')).toBe(true); - expect(steps.some((step) => step.t === 'mergeTake')).toBe(true); - expect(steps.some((step) => step.t === 'write')).toBe(true); - expect(buildMergeSteps([])).toEqual([]); - expect(buildMergeSteps([1])).toEqual([]); - }); + it('writes the values back in ascending order without changing the input', () => { + const bars = input.slice(); + for (const step of buildMergeSteps(input)) { + if (step.t === 'write') bars[step.i] = step.value; + } - it('builds quick sort steps that respect the rightmost pivot and produce candidate markers', () => { - const original = [5, 1, 4, 2, 3]; - const steps = buildQuickSteps(original); - const sorted = applySwaps(original, steps); + expect(bars).toEqual([1, 2, 2, 4, 4]); + expect(input).toEqual([4, 2, 4, 1, 2]); + }); - expect(sorted).toEqual([1, 2, 3, 4, 5]); + it('produces no steps when there is nothing to sort', () => { + expect(buildMergeSteps([])).toEqual([]); + expect(buildMergeSteps([1])).toEqual([]); + }); - let activeRange: { lo: number; hi: number } | null = null; - let currentPivot: number | null = null; - let pendingPivotSwap: Extract | null = null; + it('starts each merge by showing the two sorted halves of the highlighted range', () => { + const merges = traceMerges(input); - steps.forEach((step, index) => { - switch (step.t) { - case 'range': { - if (step.lo != null && step.hi != null) { - activeRange = { lo: step.lo, hi: step.hi }; - const next = steps[index + 1] as Step | undefined; - expect(next).toBeDefined(); - expect(next?.t).toBe('pivot'); - if (next?.t === 'pivot') { - expect(next.i).toBe(step.hi); + // 5 個を 1 個ずつに分けてから 1 列にまとめるには 4 回マージする + expect(merges).toHaveLength(4); + for (const { lo, hi, split, left, right, bars } of merges) { + // 分け目は範囲の内側にあり、左右とも 1 個以上になる + expect(split).toBeGreaterThan(lo); + expect(split).toBeLessThanOrEqual(hi); + expect(left).toEqual(bars.slice(lo, split)); + expect(right).toEqual(bars.slice(split, hi + 1)); + expect(left).toEqual(ascending(left)); + expect(right).toEqual(ascending(right)); + } + }); + + it('compares the two heads and moves the smaller one to the result, taking the left one on ties', () => { + let ties = 0; + for (const { lo, split, left, right, steps } of traceMerges(input)) { + let taken = { left: 0, right: 0 }; + steps.forEach((step, index) => { + if (step.t !== 'mergeTake') return; + const leftHead = left[taken.left]; + const rightHead = right[taken.right]; + if (leftHead != null && rightHead != null) { + // 左右の先頭の棒を比べてから、小さい方(同じ値なら左)を取る + const prev = steps[index - 1]; + expect(prev?.t).toBe('compare'); + if (prev?.t === 'compare') { + expect(pairOf(prev.i, prev.j)).toEqual([lo + taken.left, split + taken.right]); } + if (leftHead === rightHead) ties++; + expect(step.side).toBe(leftHead <= rightHead ? 'left' : 'right'); } else { - activeRange = null; + // 片方が空になったら、残った側から取る + expect(step.side).toBe(leftHead != null ? 'left' : 'right'); } - break; - } - case 'pivot': { - if (pendingPivotSwap) { - expect(step.i === pendingPivotSwap.i || step.i === pendingPivotSwap.j).toBe(true); - pendingPivotSwap = null; - } - currentPivot = step.i; - break; - } - case 'markL': { - expect(activeRange).not.toBeNull(); - expect(step.i).toBeGreaterThanOrEqual(activeRange!.lo); - expect(step.i).toBeLessThanOrEqual(activeRange!.hi); - const prev = steps[index - 1]; - expect(prev.t).toBe('compare'); - if (prev.t === 'compare') { - expect(prev.i).toBe(step.i); - expect(prev.j).toBe(currentPivot); - } - const next = steps[index + 1]; - expect(next?.t).toBe('boundary'); - if (next?.t === 'boundary') { - expect(next.show).toBe(false); - expect(next.lo ?? activeRange!.lo).toBeLessThanOrEqual(activeRange!.hi); + // 取った値と、その値を持つ棒の位置を示す + if (step.side === 'left') { + expect(step).toMatchObject({ value: leftHead, source: lo + taken.left }); + taken = { ...taken, left: taken.left + 1 }; + } else { + expect(step).toMatchObject({ value: rightHead, source: split + taken.right }); + taken = { ...taken, right: taken.right + 1 }; } - break; + }); + expect(taken).toEqual({ left: left.length, right: right.length }); + } + // 同じ値どうしの比較を含む入力で確かめる + expect(ties).toBeGreaterThan(0); + }); + + it('writes the result back into the range only after every value has been taken', () => { + for (const { lo, left, right, steps } of traceMerges(input)) { + const firstWrite = steps.findIndex(isStep('write')); + const result = steps.slice(0, firstWrite).filter(isStep('mergeTake')); + const writes = steps.slice(firstWrite); + + // 書き戻しの前に、結果が昇順で揃っている + expect(result.map((step) => step.value)).toEqual(ascending([...left, ...right])); + // 書き戻しを始めたら、比較や結果への移動はしない + expect(writes.every(isStep('write'))).toBe(true); + // 範囲の各位置に、結果の値を 1 回ずつ書き込む + expect( + writes + .filter(isStep('write')) + .map(({ i, value }) => ({ i, value })) + .sort((a, b) => a.i - b.i), + ).toEqual(result.map(({ value }, offset) => ({ i: lo + offset, value }))); + } + }); + }); + + describe('quick sort steps', () => { + const inputs = [ + { name: 'distinct values', input: [5, 1, 4, 2, 3] }, + // 左右の走査がどちらもピボットと同じ値 2 を通り、それを候補にしないことを確かめる + { name: 'values equal to the pivot', input: [2, 3, 1, 2, 2] }, + ]; + + type Partition = { + lo: number; + hi: number; + /** 範囲を表示してから消すまでのステップと、適用後の棒の並び */ + entries: { step: Step; bars: number[] }[]; + }; + + /** ステップ列を、範囲を表示している区間ごとに区切る */ + function tracePartitions(values: number[]): Partition[] { + const partitions: Partition[] = []; + let current: Partition | null = null; + for (const entry of withBars(values, buildQuickSteps(values))) { + const { step } = entry; + if (step.t === 'range') { + current = + step.lo == null || step.hi == null ? null : { lo: step.lo, hi: step.hi, entries: [] }; + if (current) partitions.push(current); + } else if (current) { + current.entries.push(entry); + } else { + // 比較・候補・交換は、範囲を表示している間にだけ行う + expect(['compare', 'markL', 'markR', 'swap']).not.toContain(step.t); } - case 'markR': { - expect(activeRange).not.toBeNull(); - expect(step.i).toBeGreaterThanOrEqual(activeRange!.lo); - expect(step.i).toBeLessThanOrEqual(activeRange!.hi); - const prev = steps[index - 1]; - expect(prev.t).toBe('compare'); - if (prev.t === 'compare') { - expect(prev.i).toBe(step.i); - expect(prev.j).toBe(currentPivot); + } + return partitions; + } + + /** 区間の最初に示すピボットの値 */ + function pivotValueOf({ entries }: Partition): number { + const first = entries.find(({ step }) => step.t === 'pivot'); + if (first?.step.t !== 'pivot' || first.step.i == null) throw new Error('pivot is not shown'); + return first.bars[first.step.i]; + } + + it.each(inputs)('sorts $name into ascending order by swaps', ({ input }) => { + const original = input.slice(); + expect(applySwaps(input, buildQuickSteps(input))).toEqual(ascending(input)); + expect(input).toEqual(original); + }); + + it.each(inputs)('uses the rightmost bar of each range as the pivot ($name)', ({ input }) => { + const partitions = tracePartitions(input); + + expect(partitions.length).toBeGreaterThan(0); + for (const { hi, entries } of partitions) { + expect(entries.find(({ step }) => step.t === 'pivot')?.step).toEqual({ t: 'pivot', i: hi }); + } + }); + + it.each(inputs)( + 'marks a larger bar on the left and a smaller bar on the right after comparing them with the pivot, then swaps them ($name)', + ({ input }) => { + let candidateSwaps = 0; + for (const partition of tracePartitions(input)) { + const { lo, hi, entries } = partition; + const pivot = pivotValueOf(partition); + const compared = new Set(); + let candL: number | null = null; + let candR: number | null = null; + for (const { step, bars } of entries) { + switch (step.t) { + case 'compare': { + // 範囲内の棒を、右端のピボットの棒と比べる + expect([step.i, step.j]).toContain(hi); + const other = step.i === hi ? step.j : step.i; + expect(other).toBeGreaterThanOrEqual(lo); + expect(other).toBeLessThan(hi); + compared.add(other); + break; + } + case 'markL': + expect(compared).toContain(step.i); + expect(bars[step.i]).toBeGreaterThan(pivot); + candL = step.i; + break; + case 'markR': + expect(compared).toContain(step.i); + expect(bars[step.i]).toBeLessThan(pivot); + candR = step.i; + break; + case 'clearMarks': + candL = null; + candR = null; + break; + case 'swap': + // ピボットを動かす交換以外は、左の候補と右の候補の入れ替え + if (step.i !== hi && step.j !== hi) { + expect(candL).not.toBeNull(); + expect(candR).not.toBeNull(); + expect(candL!).toBeLessThan(candR!); + expect(pairOf(step.i, step.j)).toEqual([candL, candR]); + candidateSwaps++; + } + break; + default: + break; + } } - break; } - case 'swap': { - if (activeRange) { - expect(step.i).toBeGreaterThanOrEqual(activeRange.lo); - expect(step.i).toBeLessThanOrEqual(activeRange.hi); - expect(step.j).toBeGreaterThanOrEqual(activeRange.lo); - expect(step.j).toBeLessThanOrEqual(activeRange.hi); - if (step.j === activeRange.hi) { - pendingPivotSwap = step; - } else { - const window = steps.slice(index + 1, index + 4); - const boundary = window.find((s) => s.t === 'boundary'); - expect(boundary).toBeDefined(); - expect(boundary?.show).toBe(false); - } + // 候補どうしの入れ替えが起きる入力で確かめる + expect(candidateSwaps).toBeGreaterThan(0); + }, + ); + + it.each(inputs)( + 'keeps only bars not greater than the pivot on the left of the boundary ($name)', + ({ input }) => { + for (const partition of tracePartitions(input)) { + const { lo, hi, entries } = partition; + const pivot = pivotValueOf(partition); + for (const { step, bars } of entries) { + if (step.t !== 'boundary') continue; + expect(step.k).toBeGreaterThanOrEqual(lo); + expect(step.k).toBeLessThanOrEqual(hi); + bars.slice(lo, step.k).forEach((value) => expect(value).toBeLessThanOrEqual(pivot)); } - break; } - default: - break; - } - }); + }, + ); - const hasCandidateMarks = - steps.some((step) => step.t === 'markL') && steps.some((step) => step.t === 'markR'); - expect(hasCandidateMarks).toBe(true); + it.each(inputs)( + 'moves the pivot between the bars not greater and the bars not smaller than it ($name)', + ({ input }) => { + for (const partition of tracePartitions(input)) { + const { lo, hi, entries } = partition; + const pivot = pivotValueOf(partition); + const pivots = entries.filter(({ step }) => step.t === 'pivot'); + const last = pivots[pivots.length - 1]; + if (last?.step.t !== 'pivot' || last.step.i == null) + throw new Error('pivot is not placed'); + + const placed = last.step.i; + expect(last.bars[placed]).toBe(pivot); + last.bars.slice(lo, placed).forEach((value) => expect(value).toBeLessThanOrEqual(pivot)); + last.bars + .slice(placed + 1, hi + 1) + .forEach((value) => expect(value).toBeGreaterThanOrEqual(pivot)); + } + }, + ); }); it('generates permutations covering the slider specification bounds', () => {