refactor: replace mocked time with injected clock prop and implement conversion logic

This commit is contained in:
Loic Coenen
2026-06-24 16:54:57 +02:00
committed by Loic Coenen (aider)
parent 88ce663b44
commit 2c84312947
7 changed files with 117 additions and 112 deletions

View File

@@ -1,74 +1,54 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { BerlinClock } from '../containers/BerlinClock'; import { BerlinClock } from '../containers/BerlinClock';
import { LampState } from '../types'; import { LampState } from '../types';
import { useCurrentTime } from '../hooks/useCurrentTime';
import * as utils from '../utils';
// Mock the hook and the converter
vi.mock('../hooks/useCurrentTime');
vi.mock('../utils');
const mockedUseCurrentTime = vi.mocked(useCurrentTime);
const mockedDateToBerlin = vi.mocked(utils.dateToBerlin);
describe('BerlinClock', () => { describe('BerlinClock', () => {
beforeEach(() => { const testClock = {
// Set a fixed date for the hook secondsRow: [LampState.Y],
mockedUseCurrentTime.mockReturnValue({ fiveHours: [LampState.R, LampState.R, LampState.R, LampState.O],
currentTime: new Date(2025, 0, 1, 14, 30, 0), oneHour: [LampState.R, LampState.R, LampState.R, LampState.R],
setCurrentTime: vi.fn(), fiveMinutes: [
}); LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
// Provide a controlled BerlinClock output for that time LampState.O, LampState.O, LampState.O,
mockedDateToBerlin.mockReturnValue({ LampState.O, LampState.O,
secondsRow: [LampState.Y], ],
fiveHours: [LampState.R, LampState.R, LampState.R, LampState.O], oneMinute: [LampState.O, LampState.O, LampState.O, LampState.O],
oneHour: [LampState.R, LampState.R, LampState.R, LampState.R], };
fiveMinutes: [
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O,
],
oneMinute: [LampState.O, LampState.O, LampState.O, LampState.O],
});
});
it('renders nothing when disabled', () => { it('renders nothing when disabled', () => {
const { container } = render(<BerlinClock enabled={false} />); const { container } = render(<BerlinClock enabled={false} clock={testClock} />);
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
it('renders all five rows when enabled', () => { it('renders all five rows when enabled', () => {
render(<BerlinClock enabled={true} />); render(<BerlinClock enabled={true} clock={testClock} />);
expect(screen.getByTestId('seconds-lamp')).toBeInTheDocument(); expect(screen.getByTestId('seconds-row')).toBeInTheDocument();
expect(screen.getByTestId('five-hours-row')).toBeInTheDocument(); expect(screen.getByTestId('five-hours-row')).toBeInTheDocument();
expect(screen.getByTestId('single-hours-row')).toBeInTheDocument(); expect(screen.getByTestId('single-hours-row')).toBeInTheDocument();
expect(screen.getByTestId('five-minutes-row')).toBeInTheDocument(); expect(screen.getByTestId('five-minutes-row')).toBeInTheDocument();
expect(screen.getByTestId('single-minutes-row')).toBeInTheDocument(); expect(screen.getByTestId('single-minutes-row')).toBeInTheDocument();
}); });
it('renders the seconds lamp with correct aria-label', () => { it('renders the correct number of lamps in seconds-row', () => {
render(<BerlinClock enabled={true} />); render(<BerlinClock enabled={true} clock={testClock} />);
const secLamp = screen.getByTestId('seconds-lamp').querySelector('[data-testid="lamp-0"]'); const secRow = screen.getByTestId('seconds-row');
expect(secLamp).toHaveAttribute('aria-label', 'Y'); const lampLine = secRow.querySelector('[data-testid="lamp-line"]');
expect(lampLine?.children).toHaveLength(1);
}); });
it('renders the correct number of lamps in five-hours-row', () => { it('renders the correct number of lamps in five-hours-row', () => {
render(<BerlinClock enabled={true} />); render(<BerlinClock enabled={true} clock={testClock} />);
const container = screen.getByTestId('five-hours-row').querySelector('[data-testid="lamp-line"]'); const row = screen.getByTestId('five-hours-row');
expect(container?.children).toHaveLength(4); const lampLine = row.querySelector('[data-testid="lamp-line"]');
expect(lampLine?.children).toHaveLength(4);
}); });
it('renders correct aria-labels in five-hours-row', () => { it('renders correct aria-labels in the seconds lamp', () => {
render(<BerlinClock enabled={true} />); render(<BerlinClock enabled={true} clock={testClock} />);
const container = screen.getByTestId('five-hours-row').querySelector('[data-testid="lamp-line"]'); const secRow = screen.getByTestId('seconds-row');
const spans = container?.querySelectorAll('span'); const span = secRow.querySelector('span');
expect(spans).toHaveLength(4); expect(span).toHaveAttribute('aria-label', 'Y');
const expected = ['R', 'R', 'R', 'O'];
spans?.forEach((span, i) => {
expect(span).toHaveAttribute('aria-label', expected[i]);
});
}); });
}); });

View File

@@ -106,7 +106,7 @@ describe('Utils', () => {
fiveMinutes: [ fiveMinutes: [
LampState.Y, LampState.Y, LampState.R, LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R, LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.O, LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O, LampState.O, LampState.O,
], ],
oneMinute: [LampState.Y, LampState.Y, LampState.Y, LampState.Y], oneMinute: [LampState.Y, LampState.Y, LampState.Y, LampState.Y],

View File

@@ -2,8 +2,9 @@ import { LampState } from '../types';
interface LampProps { interface LampProps {
state: LampState; state: LampState;
id?: string;
} }
export function Lamp({ state }: LampProps) { export function Lamp({ state, id }: LampProps) {
return <span aria-label={state} />; return <span data-testid={id} aria-label={state} />;
} }

View File

@@ -3,13 +3,14 @@ import { Lamp } from './Lamp';
interface LampLineProps { interface LampLineProps {
states: LampState[]; states: LampState[];
rowTestId?: string;
} }
export function LampLine({ states }: LampLineProps) { export function LampLine({ states, rowTestId }: LampLineProps) {
return ( return (
<div data-testid="lamp-line"> <div data-testid={rowTestId ?? "lamp-line"}>
{states.map((state, index) => ( {states.map((state, index) => (
<Lamp key={index} state={state} /> <Lamp key={index} state={state} id={`lamp-${index}`} />
))} ))}
</div> </div>
); );

View File

@@ -1,49 +1,24 @@
import { useState } from 'react'; import { Lamp } from "../components/Lamp";
import { Lamp } from '../components/Lamp'; import { LampLine } from "../components/LampLine";
import { LampLine } from '../components/LampLine'; import type { BerlinClock as BerlinClockType } from "../types";
import { useCurrentTime } from '../hooks/useCurrentTime';
import { dateToBerlin } from '../utils';
interface BerlinClockProps { interface BerlinClockProps {
enabled: boolean; enabled: boolean;
clock: BerlinClockType;
} }
export function BerlinClock({ enabled }: BerlinClockProps) { export function BerlinClock({ enabled, clock }: BerlinClockProps) {
const { currentTime } = useCurrentTime();
const berlin = dateToBerlin(currentTime);
if (!enabled) return null; if (!enabled) return null;
return ( return (
<div> <div>
{/* Seconds lamp */} <div data-testid="seconds-row">
<div data-testid="seconds-lamp"> <Lamp state={clock.secondsRow[0]} id="seconds-lamp" />
{berlin.secondsRow.length > 0 && (
<span data-testid="lamp-0" aria-label={berlin.secondsRow[0]}>
{/* optional visual */}
</span>
)}
</div>
{/* Five hours row */}
<div data-testid="five-hours-row">
<LampLine states={berlin.fiveHours} />
</div>
{/* Single hours row */}
<div data-testid="single-hours-row">
<LampLine states={berlin.oneHour} />
</div>
{/* Five minutes row */}
<div data-testid="five-minutes-row">
<LampLine states={berlin.fiveMinutes} />
</div>
{/* Single minutes row */}
<div data-testid="single-minutes-row">
<LampLine states={berlin.oneMinute} />
</div> </div>
<LampLine states={clock.fiveHours} rowTestId="five-hours-row" />
<LampLine states={clock.oneHour} rowTestId="single-hours-row" />
<LampLine states={clock.fiveMinutes} rowTestId="five-minutes-row" />
<LampLine states={clock.oneMinute} rowTestId="single-minutes-row" />
</div> </div>
); );
} }

View File

@@ -1,15 +1,14 @@
import { useState } from 'react'; import { useState } from "react";
import { BerlinClock } from './BerlinClock'; import { BerlinClock } from "./BerlinClock";
import { DigitalClock } from './DigitalClock'; import { DigitalClock } from "./DigitalClock";
import { useCurrentTime } from '../hooks/useCurrentTime'; import { useCurrentTime } from "../hooks/useCurrentTime";
import { dateToBerlin } from "../utils";
export function TimeConverter() { export function TimeConverter() {
const { currentTime, setCurrentTime } = useCurrentTime(); const { currentTime, setCurrentTime } = useCurrentTime();
const [useSystemTime, setUseSystemTime] = useState(false); const [useSystemTime, setUseSystemTime] = useState(false);
const enabled = !useSystemTime;
const handleTimeChange = (newTime: Date) => { const berlinClock = dateToBerlin(currentTime);
setCurrentTime(newTime);
};
return ( return (
<div> <div>
@@ -22,11 +21,11 @@ export function TimeConverter() {
/> />
Use system time Use system time
</label> </label>
<BerlinClock enabled={!useSystemTime} /> <BerlinClock enabled={enabled} clock={berlinClock} />
<DigitalClock <DigitalClock
enabled={!useSystemTime} enabled={enabled}
currentTime={currentTime} currentTime={currentTime}
onTimeChange={handleTimeChange} onTimeChange={setCurrentTime}
/> />
</div> </div>
); );

View File

@@ -1,25 +1,74 @@
import type { BerlinClock } from '../types'; import type { BerlinClock } from '../types';
import { LampState } from '../types';
export function dateToDigital(date: Date): string { export function dateToDigital(date: Date): string {
// returns "HH:mm:ss" const pad = (n: number) => n.toString().padStart(2, '0');
return ''; return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
} }
export function digitalToDate(digital: string): Date { export function digitalToDate(digital: string): Date {
// parses "HH:mm:ss" const parts = digital.split(':');
return new Date(); if (parts.length !== 3) return new Date(NaN);
const [h, m, s] = parts.map(Number);
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
const d = new Date();
d.setHours(h, m, s, 0);
return d;
} }
export function dateToBerlin(date: Date): BerlinClock { export function dateToBerlin(date: Date): BerlinClock {
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const secondsLamp = seconds % 2 === 0 ? [LampState.Y] : [LampState.O];
const fiveHoursCount = Math.floor(hours / 5);
const fiveHours: LampState[] = Array.from({ length: 4 }, (_, i) =>
i < fiveHoursCount ? LampState.R : LampState.O,
);
const oneHourCount = hours % 5;
const oneHour: LampState[] = Array.from({ length: 4 }, (_, i) =>
i < oneHourCount ? LampState.R : LampState.O,
);
const fiveMinutesCount = Math.floor(minutes / 5);
const fiveMinutes: LampState[] = Array.from({ length: 11 }, (_, i) => {
if (i < fiveMinutesCount) {
// third, sixth, ninth positions (indices 2,5,8) are red (R)
return (i + 1) % 3 === 0 ? LampState.R : LampState.Y;
}
return LampState.O;
});
const oneMinuteCount = minutes % 5;
const oneMinute: LampState[] = Array.from({ length: 4 }, (_, i) =>
i < oneMinuteCount ? LampState.Y : LampState.O,
);
return { return {
secondsRow: [], secondsRow: secondsLamp,
fiveHours: [], fiveHours,
oneHour: [], oneHour,
fiveMinutes: [], fiveMinutes,
oneMinute: [], oneMinute,
}; };
} }
export function berlinToDate(berlin: BerlinClock): Date { export function berlinToDate(berlin: BerlinClock): Date {
return new Date(); const countR = (arr: LampState[]) =>
arr.filter((s) => s === LampState.R).length;
const countY = (arr: LampState[]) =>
arr.filter((s) => s === LampState.Y).length;
const countYorR = (arr: LampState[]) =>
arr.filter((s) => s === LampState.R || s === LampState.Y).length;
const hours = countR(berlin.fiveHours) * 5 + countR(berlin.oneHour);
const minutes =
countYorR(berlin.fiveMinutes) * 5 + countY(berlin.oneMinute);
const d = new Date();
d.setHours(hours, minutes, 0, 0);
return d;
} }