Compare commits
10 Commits
2c84312947
...
cf60ae0ab7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf60ae0ab7 | ||
|
|
80bb62cf50 | ||
|
|
0b8d76a057 | ||
|
|
3fed6dc168 | ||
|
|
abf8b13fc7 | ||
|
|
0b3a1d4d66 | ||
|
|
a5e300d238 | ||
|
|
261c978037 | ||
|
|
d33c434fe9 | ||
|
|
d8348a64ea |
145
README.md
145
README.md
@@ -1,73 +1,100 @@
|
|||||||
# React + TypeScript + Vite
|
# Berlin Clock (Mengenlehreuhr)
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
A React + TypeScript application that implements the **Berlin Clock** (also known as the Mengenlehreuhr), a system invented in Berlin in 1975 to represent the time using coloured lamps.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Features
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
- Real-time clock that automatically advances every second using the system time.
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
- **Converter mode** – uncheck the "Use system time" checkbox to switch to manual time input. The digital time display becomes editable, letting you set any time and immediately see its Berlin‑Clock representation.
|
||||||
|
- Full Berlin Clock layout:
|
||||||
|
- **Seconds lamp** (top, round) – blinks yellow every second.
|
||||||
|
- **Five‑hour row** – four red lamps, each representing 5 hours.
|
||||||
|
- **One‑hour row** – four red lamps, each representing 1 hour.
|
||||||
|
- **Five‑minute row** – eleven lamps, each representing 5 minutes. Every third lamp is red to mark quarter hours; the remaining lamps are yellow.
|
||||||
|
- **One‑minute row** – four yellow lamps, each representing 1 minute.
|
||||||
|
|
||||||
## React Compiler
|
## Technologies
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
- [React](https://react.dev)
|
||||||
|
- [TypeScript](https://www.typescriptlang.org)
|
||||||
|
- [Vite](https://vitejs.dev)
|
||||||
|
- [Cypress](https://www.cypress.io) (end‑to‑end tests)
|
||||||
|
- [Vitest](https://vitest.dev) (unit tests, via the community)
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Getting Started
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
### Prerequisites
|
||||||
|
|
||||||
```js
|
- [Node.js](https://nodejs.org) (version 18 or later recommended)
|
||||||
export default defineConfig([
|
- npm, yarn, or pnpm
|
||||||
globalIgnores(["dist"]),
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,tsx}"],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
### Installation
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
```bash
|
||||||
],
|
# Clone the repository
|
||||||
languageOptions: {
|
git clone <repository-url>
|
||||||
parserOptions: {
|
cd <repository-folder>
|
||||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
# Install dependencies
|
||||||
},
|
npm install
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
### Running the Development Server
|
||||||
|
|
||||||
```js
|
```bash
|
||||||
// eslint.config.js
|
npm run dev
|
||||||
import reactX from "eslint-plugin-react-x";
|
|
||||||
import reactDom from "eslint-plugin-react-dom";
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(["dist"]),
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,tsx}"],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs["recommended-typescript"],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) in your browser to see the application.
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The output will be placed in the `dist` directory.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests (Vitest)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# End‑to‑end tests (Cypress)
|
||||||
|
npx cypress run
|
||||||
|
|
||||||
|
# Open Cypress test runner
|
||||||
|
npx cypress open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When the application starts, the Berlin Clock shows the **current system time** (the "Use system time" checkbox is checked).
|
||||||
|
|
||||||
|
- **System clock mode** – the digital display shows the current time and is read‑only. The Berlin clock updates every second.
|
||||||
|
- **Manual converter mode** – uncheck **"Use system time"**. The digital time input becomes editable. Type a new time (hh:mm:ss) or use the browser’s native date/time picker (if available). As you change the value, the Berlin Clock immediately reflects the entered time.
|
||||||
|
|
||||||
|
> The Berlin Clock layout is read‑only; it always shows the time represented by the digital input.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── App.tsx – Main application component
|
||||||
|
├── components/
|
||||||
|
│ ├── Lamp.tsx – Single lamp with state and border radius logic
|
||||||
|
│ └── LampLine.tsx – Row of lamps (used for five‑hour, one‑hour, etc.)
|
||||||
|
├── containers/
|
||||||
|
│ ├── BerlinClock.tsx – Visual representation of the Berlin clock
|
||||||
|
│ ├── DigitalClock.tsx – Input field for digital time
|
||||||
|
│ └── TimeConverter.tsx– Orchestrates clock switching and time sync
|
||||||
|
├── hooks/
|
||||||
|
│ └── useCurrentTime.ts– Hook to manage current time state
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts – Type definitions (`LampState`, `BerlinClock`)
|
||||||
|
├── utils/
|
||||||
|
│ └── index.ts – Conversion functions between Date and Berlin Clock
|
||||||
|
└── __tests__/ – Unit tests corresponding to the source files
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -209,4 +209,42 @@ describe("Berlin Clock", () => {
|
|||||||
assertFullClock("11:37:01", "ORROOROOOYYRYYRYOOOOYYOO");
|
assertFullClock("11:37:01", "ORROOROOOYYRYYRYOOOOYYOO");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Use System Time", () => {
|
||||||
|
it("should update every second and the seconds lamp should blink", () => {
|
||||||
|
// Freeze the clock at a known time (even seconds e.g. 0, so lamp is Y)
|
||||||
|
const now = new Date();
|
||||||
|
now.setSeconds(0, 0); // seconds = 0 → even → lamp = Y
|
||||||
|
cy.clock(now);
|
||||||
|
|
||||||
|
// Visit the page (clock is frozen from this point)
|
||||||
|
cy.visit("/");
|
||||||
|
|
||||||
|
// Check the "Use system time" checkbox
|
||||||
|
cy.get('[data-testid="use-system-time"]').check();
|
||||||
|
|
||||||
|
// Verify initial seconds lamp is Y (seconds=0)
|
||||||
|
cy.get('[data-testid="seconds-lamp"]').should(
|
||||||
|
"have.attr",
|
||||||
|
"aria-label",
|
||||||
|
"Y",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance time by 1 second → seconds=1 (odd) → lamp toggles to O
|
||||||
|
cy.tick(1000);
|
||||||
|
cy.get('[data-testid="seconds-lamp"]').should(
|
||||||
|
"have.attr",
|
||||||
|
"aria-label",
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance another second → seconds=2 (even) → lamp back to Y
|
||||||
|
cy.tick(1000);
|
||||||
|
cy.get('[data-testid="seconds-lamp"]').should(
|
||||||
|
"have.attr",
|
||||||
|
"aria-label",
|
||||||
|
"Y",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
21
src/App.css
21
src/App.css
@@ -182,3 +182,24 @@
|
|||||||
border-right-color: var(--border);
|
border-right-color: var(--border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.berlin-clock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seconds-lamp-container {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 330px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|||||||
10
src/App.tsx
10
src/App.tsx
@@ -3,7 +3,15 @@ import "./App.css";
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="clock-container">
|
||||||
|
<h1>Berlin Clock</h1>
|
||||||
|
<p>
|
||||||
|
The Berlin Clock (also known as the Mengenlehreuhr) uses lamps to represent the time.
|
||||||
|
The top lamp blinks every second. The next row of four red lamps each represents five hours.
|
||||||
|
The following row of four red lamps each represents one hour.
|
||||||
|
Then the next row of eleven lamps shows five‑minute blocks (every third lamp is red to
|
||||||
|
mark quarter hours). The last row shows the remaining minutes one by one.
|
||||||
|
</p>
|
||||||
<TimeConverter />
|
<TimeConverter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,54 +1,61 @@
|
|||||||
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";
|
||||||
|
|
||||||
describe('BerlinClock', () => {
|
describe("BerlinClock", () => {
|
||||||
const testClock = {
|
const testClock = {
|
||||||
secondsRow: [LampState.Y],
|
secondsRow: [LampState.Y],
|
||||||
fiveHours: [LampState.R, LampState.R, LampState.R, LampState.O],
|
fiveHours: [LampState.R, LampState.R, LampState.R, LampState.O],
|
||||||
oneHour: [LampState.R, LampState.R, LampState.R, LampState.R],
|
oneHour: [LampState.R, LampState.R, LampState.R, LampState.R],
|
||||||
fiveMinutes: [
|
fiveMinutes: [
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.Y,
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.Y,
|
||||||
LampState.O, LampState.O, LampState.O,
|
LampState.R,
|
||||||
LampState.O, LampState.O,
|
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],
|
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} clock={testClock} />);
|
const { container } = render(
|
||||||
expect(container.innerHTML).toBe('');
|
<BerlinClock enabled={false} clock={testClock} />,
|
||||||
|
);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all five rows when enabled', () => {
|
it("renders all five rows when enabled", () => {
|
||||||
render(<BerlinClock enabled={true} clock={testClock} />);
|
render(<BerlinClock enabled={true} clock={testClock} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('seconds-row')).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 correct number of lamps in seconds-row', () => {
|
it("renders the correct number of lamps in seconds-row", () => {
|
||||||
render(<BerlinClock enabled={true} clock={testClock} />);
|
render(<BerlinClock enabled={true} clock={testClock} />);
|
||||||
const secRow = screen.getByTestId('seconds-row');
|
const secRow = screen.getByTestId("seconds-row");
|
||||||
const lampLine = secRow.querySelector('[data-testid="lamp-line"]');
|
expect(secRow.children).toHaveLength(1);
|
||||||
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} clock={testClock} />);
|
render(<BerlinClock enabled={true} clock={testClock} />);
|
||||||
const row = screen.getByTestId('five-hours-row');
|
const row = screen.getByTestId("five-hours-row");
|
||||||
const lampLine = row.querySelector('[data-testid="lamp-line"]');
|
expect(row.children).toHaveLength(4);
|
||||||
expect(lampLine?.children).toHaveLength(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correct aria-labels in the seconds lamp', () => {
|
it("renders correct aria-labels in the seconds lamp", () => {
|
||||||
render(<BerlinClock enabled={true} clock={testClock} />);
|
render(<BerlinClock enabled={true} clock={testClock} />);
|
||||||
const secRow = screen.getByTestId('seconds-row');
|
const secRow = screen.getByTestId("seconds-row");
|
||||||
const span = secRow.querySelector('span');
|
const span = secRow.querySelector("span");
|
||||||
expect(span).toHaveAttribute('aria-label', 'Y');
|
expect(span).toHaveAttribute("aria-label", "Y");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { DigitalClock } from '../containers/DigitalClock';
|
import { DigitalClock } from "../containers/DigitalClock";
|
||||||
import * as utils from '../utils';
|
import * as utils from "../utils";
|
||||||
|
|
||||||
vi.mock('../utils');
|
vi.mock("../utils");
|
||||||
|
|
||||||
const mockedDateToDigital = vi.mocked(utils.dateToDigital);
|
const mockedDateToDigital = vi.mocked(utils.dateToDigital);
|
||||||
const mockedDigitalToDate = vi.mocked(utils.digitalToDate);
|
const mockedDigitalToDate = vi.mocked(utils.digitalToDate);
|
||||||
|
|
||||||
describe('DigitalClock', () => {
|
describe("DigitalClock", () => {
|
||||||
const onTimeChangeMock = vi.fn();
|
const onTimeChangeMock = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedDateToDigital.mockReturnValue('14:30:15');
|
mockedDateToDigital.mockReturnValue("14:30:15");
|
||||||
mockedDigitalToDate.mockImplementation((str: string) => {
|
mockedDigitalToDate.mockImplementation((str: string) => {
|
||||||
const parts = str.split(':');
|
const parts = str.split(":");
|
||||||
if (parts.length !== 3) return new Date(NaN);
|
if (parts.length !== 3) return new Date(NaN);
|
||||||
const [h, m, s] = parts.map(Number);
|
const [h, m, s] = parts.map(Number);
|
||||||
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
|
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
|
||||||
@@ -24,58 +24,57 @@ describe('DigitalClock', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders nothing when disabled', () => {
|
it("renders the input as disabled when disabled prop is true", () => {
|
||||||
const { container } = render(
|
|
||||||
<DigitalClock
|
|
||||||
enabled={false}
|
|
||||||
currentTime={new Date(2025,0,1,14,30,15)}
|
|
||||||
onTimeChange={onTimeChangeMock}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(container.innerHTML).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the digital time and input when enabled', () => {
|
|
||||||
render(
|
render(
|
||||||
<DigitalClock
|
<DigitalClock
|
||||||
enabled={true}
|
disabled={true}
|
||||||
currentTime={new Date(2025,0,1,14,30,15)}
|
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
|
||||||
onTimeChange={onTimeChangeMock}
|
onTimeChange={onTimeChangeMock}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('digital-clock')).toHaveTextContent('14:30:15');
|
const input = screen.getByTestId("time-input");
|
||||||
expect(screen.getByTestId('time-input')).toBeInTheDocument();
|
expect(input).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onTimeChange when a valid time is entered', () => {
|
it("renders the input with the correct initial value when enabled", () => {
|
||||||
render(
|
render(
|
||||||
<DigitalClock
|
<DigitalClock
|
||||||
enabled={true}
|
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
|
||||||
currentTime={new Date(2025,0,1,14,30,15)}
|
|
||||||
onTimeChange={onTimeChangeMock}
|
onTimeChange={onTimeChangeMock}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const input = screen.getByTestId('time-input');
|
const input = screen.getByTestId("time-input") as HTMLInputElement;
|
||||||
fireEvent.change(input, { target: { value: '12:00:00' } });
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe("14:30:15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onTimeChange when a valid time is entered", () => {
|
||||||
|
render(
|
||||||
|
<DigitalClock
|
||||||
|
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
|
||||||
|
onTimeChange={onTimeChangeMock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByTestId("time-input");
|
||||||
|
fireEvent.change(input, { target: { value: "12:00:00" } });
|
||||||
|
|
||||||
expect(onTimeChangeMock).toHaveBeenCalledTimes(1);
|
expect(onTimeChangeMock).toHaveBeenCalledTimes(1);
|
||||||
const expectedDate = new Date(2025, 0, 1, 12, 0, 0, 0);
|
const expectedDate = new Date(2025, 0, 1, 12, 0, 0, 0);
|
||||||
expect(onTimeChangeMock).toHaveBeenCalledWith(expectedDate);
|
expect(onTimeChangeMock).toHaveBeenCalledWith(expectedDate);
|
||||||
|
|
||||||
expect(input).toHaveValue('12:00:00');
|
expect(input).toHaveValue("12:00:00");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not call onTimeChange when an invalid time is entered', () => {
|
it("does not call onTimeChange when an invalid time is entered", () => {
|
||||||
render(
|
render(
|
||||||
<DigitalClock
|
<DigitalClock
|
||||||
enabled={true}
|
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
|
||||||
currentTime={new Date(2025,0,1,14,30,15)}
|
|
||||||
onTimeChange={onTimeChangeMock}
|
onTimeChange={onTimeChangeMock}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const input = screen.getByTestId('time-input');
|
const input = screen.getByTestId("time-input");
|
||||||
fireEvent.change(input, { target: { value: 'notatime' } });
|
fireEvent.change(input, { target: { value: "notatime" } });
|
||||||
expect(onTimeChangeMock).not.toHaveBeenCalled();
|
expect(onTimeChangeMock).not.toHaveBeenCalled();
|
||||||
expect(input).toHaveValue('notatime');
|
expect(input).toHaveValue("notatime");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from "@testing-library/react";
|
||||||
import { Lamp } from '../components/Lamp';
|
import { Lamp } from "../components/Lamp";
|
||||||
import { LampState } from '../types';
|
import { LampState } from "../types";
|
||||||
|
|
||||||
describe('Lamp', () => {
|
describe("Lamp", () => {
|
||||||
it('renders without crashing', () => {
|
it("renders without crashing", () => {
|
||||||
render(<Lamp state={LampState.Y} />);
|
render(<Lamp state={LampState.Y} />);
|
||||||
// If it doesn't throw, we're good.
|
// If it doesn't throw, we're good.
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['Y' as LampState, 'Y'],
|
["Y" as LampState, "Y"],
|
||||||
['R' as LampState, 'R'],
|
["R" as LampState, "R"],
|
||||||
['O' as LampState, 'O'],
|
["O" as LampState, "O"],
|
||||||
['N' as LampState, 'N'],
|
["N" as LampState, "N"],
|
||||||
])('displays aria-label "%s" for state %s', (state, expectedLabel) => {
|
])('displays aria-label "%s" for state %s', (state, expectedLabel) => {
|
||||||
const { container } = render(<Lamp state={state} />);
|
const { container } = render(<Lamp state={state} />);
|
||||||
// The root element should have the correct aria-label
|
// The root element should have the correct aria-label
|
||||||
const lampElement = container.firstChild as HTMLElement;
|
const lampElement = container.firstChild as HTMLElement;
|
||||||
expect(lampElement).toHaveAttribute('aria-label', expectedLabel);
|
expect(lampElement).toHaveAttribute("aria-label", expectedLabel);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from "@testing-library/react";
|
||||||
import { LampLine } from '../components/LampLine';
|
import { LampLine } from "../components/LampLine";
|
||||||
import { LampState } from '../types';
|
import { LampState } from "../types";
|
||||||
|
|
||||||
describe('LampLine', () => {
|
describe("LampLine", () => {
|
||||||
it('renders without crashing', () => {
|
it("renders without crashing", () => {
|
||||||
render(<LampLine states={[]} />);
|
render(<LampLine states={[]} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the correct number of lamps', () => {
|
it("renders the correct number of lamps", () => {
|
||||||
const states = [LampState.Y, LampState.R, LampState.O];
|
const states = [LampState.Y, LampState.R, LampState.O];
|
||||||
render(<LampLine states={states} />);
|
render(<LampLine states={states} />);
|
||||||
const container = screen.getByTestId('lamp-line');
|
const container = screen.getByTestId("lamp-line");
|
||||||
expect(container.children).toHaveLength(states.length);
|
expect(container.children).toHaveLength(states.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays correct aria-labels for each lamp', () => {
|
it("displays correct aria-labels for each lamp", () => {
|
||||||
const states = [LampState.Y, LampState.R, LampState.O, LampState.N];
|
const states = [LampState.Y, LampState.R, LampState.O, LampState.N];
|
||||||
render(<LampLine states={states} />);
|
render(<LampLine states={states} />);
|
||||||
const container = screen.getByTestId('lamp-line');
|
const container = screen.getByTestId("lamp-line");
|
||||||
const lampSpans = container.querySelectorAll('span');
|
const lampSpans = container.querySelectorAll("span");
|
||||||
expect(lampSpans).toHaveLength(states.length);
|
expect(lampSpans).toHaveLength(states.length);
|
||||||
lampSpans.forEach((span, index) => {
|
lampSpans.forEach((span, index) => {
|
||||||
expect(span).toHaveAttribute('aria-label', states[index]);
|
expect(span).toHaveAttribute("aria-label", states[index]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders no lamps when given an empty array', () => {
|
it("renders no lamps when given an empty array", () => {
|
||||||
render(<LampLine states={[]} />);
|
render(<LampLine states={[]} />);
|
||||||
const container = screen.getByTestId('lamp-line');
|
const container = screen.getByTestId("lamp-line");
|
||||||
expect(container.children).toHaveLength(0);
|
expect(container.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { TimeConverter } from '../containers/TimeConverter';
|
import { TimeConverter } from "../containers/TimeConverter";
|
||||||
import { useCurrentTime } from '../hooks/useCurrentTime';
|
import { useCurrentTime } from "../hooks/useCurrentTime";
|
||||||
|
|
||||||
vi.mock('../hooks/useCurrentTime');
|
vi.mock("../hooks/useCurrentTime");
|
||||||
vi.mock('../containers/BerlinClock', () => ({
|
vi.mock("../containers/BerlinClock", () => ({
|
||||||
BerlinClock: ({ enabled }: { enabled: boolean }) => (
|
BerlinClock: ({ enabled }: { enabled: boolean }) => (
|
||||||
<div data-testid="berlin-clock">{enabled ? 'enabled' : 'disabled'}</div>
|
<div data-testid="berlin-clock">{enabled ? "enabled" : "disabled"}</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock('../containers/DigitalClock', () => ({
|
vi.mock("../containers/DigitalClock", () => ({
|
||||||
DigitalClock: ({ enabled, currentTime, onTimeChange }: { enabled: boolean; currentTime: Date; onTimeChange: (d: Date) => void }) => (
|
DigitalClock: ({
|
||||||
|
currentTime,
|
||||||
|
onTimeChange,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
currentTime: Date;
|
||||||
|
onTimeChange: (d: Date) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const digitalTime = `${String(currentTime.getHours()).padStart(2, "0")}:${String(currentTime.getMinutes()).padStart(2, "0")}:${String(currentTime.getSeconds()).padStart(2, "0")}`;
|
||||||
|
return (
|
||||||
<div data-testid="digital-clock-wrapper">
|
<div data-testid="digital-clock-wrapper">
|
||||||
<span data-testid="digital-clock-time">{currentTime.toISOString()}</span>
|
<input
|
||||||
<button data-testid="set-time" onClick={() => onTimeChange(new Date(2025,0,1,12,0,0))}>
|
data-testid="time-input"
|
||||||
Set time
|
disabled={disabled}
|
||||||
</button>
|
value={digitalTime}
|
||||||
{!enabled && <span data-testid="disabled-indicator">disabled</span>}
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockedUseCurrentTime = vi.mocked(useCurrentTime);
|
const mockedUseCurrentTime = vi.mocked(useCurrentTime);
|
||||||
|
|
||||||
describe('TimeConverter', () => {
|
describe("TimeConverter", () => {
|
||||||
const setCurrentTimeMock = vi.fn();
|
const setCurrentTimeMock = vi.fn();
|
||||||
const baseDate = new Date(2025, 0, 1, 14, 30, 15);
|
const baseDate = new Date(2025, 0, 1, 14, 30, 15);
|
||||||
|
|
||||||
@@ -34,23 +46,65 @@ describe('TimeConverter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the checkbox, BerlinClock, and DigitalClock', () => {
|
it("renders the checkbox, BerlinClock, and DigitalClock", () => {
|
||||||
render(<TimeConverter />);
|
render(<TimeConverter />);
|
||||||
expect(screen.getByTestId('use-system-time')).toBeInTheDocument();
|
expect(screen.getByTestId("use-system-time")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('berlin-clock')).toHaveTextContent('enabled');
|
expect(screen.getByTestId("berlin-clock")).toHaveTextContent("enabled");
|
||||||
expect(screen.getByTestId('digital-clock-wrapper')).toBeInTheDocument();
|
expect(screen.getByTestId("digital-clock-wrapper")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables both clocks when checkbox is checked', () => {
|
it("disables the digital clock input when checkbox is checked", () => {
|
||||||
render(<TimeConverter />);
|
render(<TimeConverter />);
|
||||||
fireEvent.click(screen.getByTestId('use-system-time'));
|
// Initially the input is enabled
|
||||||
expect(screen.getByTestId('berlin-clock')).toHaveTextContent('disabled');
|
expect(screen.getByTestId("time-input")).not.toBeDisabled();
|
||||||
expect(screen.getByTestId('disabled-indicator')).toBeInTheDocument();
|
// Click the checkbox
|
||||||
|
fireEvent.click(screen.getByTestId("use-system-time"));
|
||||||
|
// Now the input should be disabled
|
||||||
|
expect(screen.getByTestId("time-input")).toBeDisabled();
|
||||||
|
// But the BerlinClock still appears enabled (no "disabled" text)
|
||||||
|
expect(screen.getByTestId("berlin-clock")).toHaveTextContent("enabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setCurrentTime when DigitalClock triggers onTimeChange', () => {
|
it("renders the digital clock input with the correct initial value", () => {
|
||||||
render(<TimeConverter />);
|
render(<TimeConverter />);
|
||||||
fireEvent.click(screen.getByTestId('set-time'));
|
const input = screen.getByTestId("time-input") as HTMLInputElement;
|
||||||
expect(setCurrentTimeMock).toHaveBeenCalledWith(new Date(2025, 0, 1, 12, 0, 0));
|
expect(input.value).toBe("14:30:15");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// NEW TDD TEST – should FAIL with current component code
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
it("switches to system time when 'Use system time' checkbox is checked (TDD)", () => {
|
||||||
|
// Fix system time to 15:00:00
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2025, 0, 1, 15, 0, 0));
|
||||||
|
|
||||||
|
// A mutable currentTime that the mock will return
|
||||||
|
let currentDate = new Date(2025, 0, 1, 12, 0, 0);
|
||||||
|
const setCurrentDate = vi.fn((d: Date) => {
|
||||||
|
currentDate = d;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedUseCurrentTime.mockImplementation(() => ({
|
||||||
|
currentTime: currentDate,
|
||||||
|
setCurrentTime: setCurrentDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<TimeConverter />);
|
||||||
|
|
||||||
|
// Initially the digital clock input shows the manual time 12:00:00
|
||||||
|
const input = screen.getByTestId("time-input") as HTMLInputElement;
|
||||||
|
expect(input.value).toBe("12:00:00");
|
||||||
|
|
||||||
|
// Click the "Use system time" checkbox
|
||||||
|
fireEvent.click(screen.getByTestId("use-system-time"));
|
||||||
|
|
||||||
|
// The component should have called setCurrentDate with the system time
|
||||||
|
expect(setCurrentDate).toHaveBeenCalledWith(new Date(2025, 0, 1, 15, 0, 0));
|
||||||
|
|
||||||
|
// And the input should now show the system time 15:00:00
|
||||||
|
expect(input.value).toBe("15:00:00");
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook } from "@testing-library/react";
|
||||||
import { useCurrentTime } from '../hooks/useCurrentTime';
|
import { useCurrentTime } from "../hooks/useCurrentTime";
|
||||||
|
|
||||||
describe('useCurrentTime', () => {
|
describe("useCurrentTime", () => {
|
||||||
it('returns a date and setter', () => {
|
it("returns a date and setter", () => {
|
||||||
const { result } = renderHook(() => useCurrentTime());
|
const { result } = renderHook(() => useCurrentTime());
|
||||||
expect(result.current.currentTime).toBeInstanceOf(Date);
|
expect(result.current.currentTime).toBeInstanceOf(Date);
|
||||||
expect(typeof result.current.setCurrentTime).toBe('function');
|
expect(typeof result.current.setCurrentTime).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,86 +1,157 @@
|
|||||||
import { dateToDigital, digitalToDate, dateToBerlin, berlinToDate } from '../utils';
|
import {
|
||||||
import { LampState } from '../types';
|
dateToDigital,
|
||||||
|
digitalToDate,
|
||||||
|
dateToBerlin,
|
||||||
|
berlinToDate,
|
||||||
|
} from "../utils";
|
||||||
|
import { LampState } from "../types";
|
||||||
|
|
||||||
describe('Utils', () => {
|
describe("Utils", () => {
|
||||||
describe('dateToDigital', () => {
|
describe("dateToDigital", () => {
|
||||||
it('returns "HH:mm:ss" for a given date', () => {
|
it('returns "HH:mm:ss" for a given date', () => {
|
||||||
const date = new Date(2025, 0, 1, 14, 5, 9);
|
const date = new Date(2025, 0, 1, 14, 5, 9);
|
||||||
expect(dateToDigital(date)).toBe('14:05:09');
|
expect(dateToDigital(date)).toBe("14:05:09");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pads single-digit hours, minutes, seconds', () => {
|
it("pads single-digit hours, minutes, seconds", () => {
|
||||||
const date = new Date(2025, 0, 1, 2, 3, 4);
|
const date = new Date(2025, 0, 1, 2, 3, 4);
|
||||||
expect(dateToDigital(date)).toBe('02:03:04');
|
expect(dateToDigital(date)).toBe("02:03:04");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('digitalToDate', () => {
|
describe("digitalToDate", () => {
|
||||||
it('parses a valid digital string', () => {
|
it("parses a valid digital string", () => {
|
||||||
const result = digitalToDate('12:34:56');
|
const result = digitalToDate("12:34:56");
|
||||||
expect(result).toBeInstanceOf(Date);
|
expect(result).toBeInstanceOf(Date);
|
||||||
expect(result.getHours()).toBe(12);
|
expect(result.getHours()).toBe(12);
|
||||||
expect(result.getMinutes()).toBe(34);
|
expect(result.getMinutes()).toBe(34);
|
||||||
expect(result.getSeconds()).toBe(56);
|
expect(result.getSeconds()).toBe(56);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an invalid date for invalid input', () => {
|
it("returns an invalid date for invalid input", () => {
|
||||||
const result = digitalToDate('abc');
|
const result = digitalToDate("abc");
|
||||||
expect(isNaN(result.getTime())).toBe(true);
|
expect(isNaN(result.getTime())).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dateToBerlin', () => {
|
describe("dateToBerlin", () => {
|
||||||
const makeTime = (h: number, m: number, s: number) => {
|
const makeTime = (h: number, m: number, s: number) => {
|
||||||
const d = new Date(2025, 0, 1, h, m, s);
|
const d = new Date(2025, 0, 1, h, m, s);
|
||||||
return d;
|
return d;
|
||||||
};
|
};
|
||||||
|
|
||||||
it('returns correct clock for 00:00:00', () => {
|
it("returns correct clock for 00:00:00", () => {
|
||||||
const clock = dateToBerlin(makeTime(0, 0, 0));
|
const clock = dateToBerlin(makeTime(0, 0, 0));
|
||||||
expect(clock.secondsRow).toEqual([LampState.Y]);
|
expect(clock.secondsRow).toEqual([LampState.Y]);
|
||||||
expect(clock.fiveHours).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]);
|
expect(clock.fiveHours).toEqual([
|
||||||
expect(clock.oneHour).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]);
|
LampState.O,
|
||||||
expect(clock.fiveMinutes).toEqual([
|
LampState.O,
|
||||||
LampState.O, LampState.O, LampState.O,
|
LampState.O,
|
||||||
LampState.O, LampState.O, LampState.O,
|
LampState.O,
|
||||||
LampState.O, LampState.O, LampState.O,
|
]);
|
||||||
LampState.O, LampState.O,
|
expect(clock.oneHour).toEqual([
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
]);
|
||||||
|
expect(clock.fiveMinutes).toEqual([
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
]);
|
||||||
|
expect(clock.oneMinute).toEqual([
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
]);
|
]);
|
||||||
expect(clock.oneMinute).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct clock for 23:59:59', () => {
|
it("returns correct clock for 23:59:59", () => {
|
||||||
const clock = dateToBerlin(makeTime(23, 59, 59));
|
const clock = dateToBerlin(makeTime(23, 59, 59));
|
||||||
expect(clock.secondsRow).toEqual([LampState.O]);
|
expect(clock.secondsRow).toEqual([LampState.O]);
|
||||||
expect(clock.fiveHours).toEqual([LampState.R, LampState.R, LampState.R, LampState.R]);
|
expect(clock.fiveHours).toEqual([
|
||||||
expect(clock.oneHour).toEqual([LampState.R, LampState.R, LampState.R, LampState.O]);
|
LampState.R,
|
||||||
expect(clock.fiveMinutes).toEqual([
|
LampState.R,
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.R,
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.R,
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
]);
|
||||||
LampState.Y, LampState.Y,
|
expect(clock.oneHour).toEqual([
|
||||||
|
LampState.R,
|
||||||
|
LampState.R,
|
||||||
|
LampState.R,
|
||||||
|
LampState.O,
|
||||||
|
]);
|
||||||
|
expect(clock.fiveMinutes).toEqual([
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.R,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.R,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.R,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
]);
|
||||||
|
expect(clock.oneMinute).toEqual([
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
]);
|
]);
|
||||||
expect(clock.oneMinute).toEqual([LampState.Y, LampState.Y, LampState.Y, LampState.Y]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct clock for 12:34:00', () => {
|
it("returns correct clock for 12:34:00", () => {
|
||||||
const clock = dateToBerlin(makeTime(12, 34, 0));
|
const clock = dateToBerlin(makeTime(12, 34, 0));
|
||||||
expect(clock.secondsRow).toEqual([LampState.Y]);
|
expect(clock.secondsRow).toEqual([LampState.Y]);
|
||||||
expect(clock.fiveHours).toEqual([LampState.R, LampState.R, LampState.O, LampState.O]);
|
expect(clock.fiveHours).toEqual([
|
||||||
expect(clock.oneHour).toEqual([LampState.R, LampState.R, LampState.O, LampState.O]);
|
LampState.R,
|
||||||
|
LampState.R,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
]);
|
||||||
|
expect(clock.oneHour).toEqual([
|
||||||
|
LampState.R,
|
||||||
|
LampState.R,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
]);
|
||||||
const expectedFiveMinutes = [
|
const expectedFiveMinutes = [
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.Y,
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.Y,
|
||||||
LampState.O, LampState.O, LampState.O,
|
LampState.R,
|
||||||
LampState.O, LampState.O,
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.R,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
|
LampState.O,
|
||||||
];
|
];
|
||||||
expect(clock.fiveMinutes).toEqual(expectedFiveMinutes);
|
expect(clock.fiveMinutes).toEqual(expectedFiveMinutes);
|
||||||
expect(clock.oneMinute).toEqual([LampState.Y, LampState.Y, LampState.Y, LampState.Y]);
|
expect(clock.oneMinute).toEqual([
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('berlinToDate', () => {
|
describe("berlinToDate", () => {
|
||||||
it('roundtrips correctly: dateToBerlin then berlinToDate returns the original date (seconds floor to even)', () => {
|
it("roundtrips correctly: dateToBerlin then berlinToDate returns the original date (seconds floor to even)", () => {
|
||||||
const original = new Date(2025, 0, 1, 14, 30, 0);
|
const original = new Date(2025, 0, 1, 14, 30, 0);
|
||||||
const clock = dateToBerlin(original);
|
const clock = dateToBerlin(original);
|
||||||
const result = berlinToDate(clock);
|
const result = berlinToDate(clock);
|
||||||
@@ -89,7 +160,7 @@ describe('Utils', () => {
|
|||||||
expect(result.getSeconds()).toBe(0);
|
expect(result.getSeconds()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('roundtrips with odd seconds: result seconds will be zero (only parity stored)', () => {
|
it("roundtrips with odd seconds: result seconds will be zero (only parity stored)", () => {
|
||||||
const original = new Date(2025, 0, 1, 23, 59, 59);
|
const original = new Date(2025, 0, 1, 23, 59, 59);
|
||||||
const clock = dateToBerlin(original);
|
const clock = dateToBerlin(original);
|
||||||
const result = berlinToDate(clock);
|
const result = berlinToDate(clock);
|
||||||
@@ -98,16 +169,23 @@ describe('Utils', () => {
|
|||||||
expect(result.getSeconds()).toBe(0);
|
expect(result.getSeconds()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts a known Berlin clock to the correct time', () => {
|
it("converts a known Berlin clock to the correct time", () => {
|
||||||
const clock = {
|
const clock = {
|
||||||
secondsRow: [LampState.Y],
|
secondsRow: [LampState.Y],
|
||||||
fiveHours: [LampState.R, LampState.R, LampState.O, LampState.O],
|
fiveHours: [LampState.R, LampState.R, LampState.O, LampState.O],
|
||||||
oneHour: [LampState.R, LampState.R, LampState.O, LampState.O],
|
oneHour: [LampState.R, LampState.R, LampState.O, LampState.O],
|
||||||
fiveMinutes: [
|
fiveMinutes: [
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.Y,
|
||||||
LampState.Y, LampState.Y, LampState.R,
|
LampState.Y,
|
||||||
LampState.O, LampState.O, LampState.O,
|
LampState.R,
|
||||||
LampState.O, LampState.O,
|
LampState.Y,
|
||||||
|
LampState.Y,
|
||||||
|
LampState.R,
|
||||||
|
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],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import { LampState } from '../types';
|
import React from "react";
|
||||||
|
import { LampState } from "../types";
|
||||||
|
|
||||||
interface LampProps {
|
interface LampProps {
|
||||||
state: LampState;
|
state: LampState;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
variant?: "alone" | "first" | "last" | "middle";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Lamp({ state, id }: LampProps) {
|
function getBackgroundColor(state: LampState): string {
|
||||||
return <span data-testid={id} aria-label={state} />;
|
if (state === LampState.R) return "#ff3333";
|
||||||
|
if (state === LampState.Y) return "#ffcc00";
|
||||||
|
return "#333";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBorderRadius(variant?: LampProps["variant"]): string {
|
||||||
|
if (variant === "alone") return "50%";
|
||||||
|
if (variant === "first") return "16px 0 0 16px";
|
||||||
|
if (variant === "last") return "0 16px 16px 0";
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Lamp({ state, id, variant }: LampProps) {
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
width: variant === "alone" ? 50 : "100%",
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: getBackgroundColor(state),
|
||||||
|
borderRadius: getBorderRadius(variant),
|
||||||
|
margin: 1,
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
};
|
||||||
|
|
||||||
|
return <span data-testid={id} aria-label={state} style={style} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LampState } from '../types';
|
import { LampState } from "../types";
|
||||||
import { Lamp } from './Lamp';
|
import { Lamp } from "./Lamp";
|
||||||
|
|
||||||
interface LampLineProps {
|
interface LampLineProps {
|
||||||
states: LampState[];
|
states: LampState[];
|
||||||
@@ -7,11 +7,36 @@ interface LampLineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LampLine({ states, rowTestId }: LampLineProps) {
|
export function LampLine({ states, rowTestId }: LampLineProps) {
|
||||||
|
const len = states.length;
|
||||||
return (
|
return (
|
||||||
<div data-testid={rowTestId ?? "lamp-line"}>
|
<div
|
||||||
{states.map((state, index) => (
|
data-testid={rowTestId ?? "lamp-line"}
|
||||||
<Lamp key={index} state={state} id={`lamp-${index}`} />
|
style={{ display: "flex", marginBottom: 10, width: "100%", justifyContent: "space-evenly" }}
|
||||||
))}
|
>
|
||||||
|
{states.map((state, index) => {
|
||||||
|
let variant: "alone" | "first" | "last" | "middle" | undefined;
|
||||||
|
if (len === 1) {
|
||||||
|
variant = "alone";
|
||||||
|
} else if (index === 0) {
|
||||||
|
variant = "first";
|
||||||
|
} else if (index === len - 1) {
|
||||||
|
variant = "last";
|
||||||
|
} else {
|
||||||
|
variant = "middle";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{ flex: 1, display: "flex" }}
|
||||||
|
>
|
||||||
|
<Lamp
|
||||||
|
state={state}
|
||||||
|
id={`lamp-${index}`}
|
||||||
|
variant={variant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ export function BerlinClock({ enabled, clock }: BerlinClockProps) {
|
|||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="berlin-clock" style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
|
||||||
<div data-testid="seconds-row">
|
<div className="seconds-lamp-container" data-testid="seconds-row" style={{ width: "100%", display: "flex", justifyContent: "center" }}>
|
||||||
<Lamp state={clock.secondsRow[0]} id="seconds-lamp" />
|
<Lamp state={clock.secondsRow[0]} variant="alone" id="seconds-lamp" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="lamp-row" style={{ width: "100%" }}>
|
||||||
<LampLine states={clock.fiveHours} rowTestId="five-hours-row" />
|
<LampLine states={clock.fiveHours} rowTestId="five-hours-row" />
|
||||||
|
</div>
|
||||||
|
<div className="lamp-row" style={{ width: "100%" }}>
|
||||||
<LampLine states={clock.oneHour} rowTestId="single-hours-row" />
|
<LampLine states={clock.oneHour} rowTestId="single-hours-row" />
|
||||||
|
</div>
|
||||||
|
<div className="lamp-row" style={{ width: "100%" }}>
|
||||||
<LampLine states={clock.fiveMinutes} rowTestId="five-minutes-row" />
|
<LampLine states={clock.fiveMinutes} rowTestId="five-minutes-row" />
|
||||||
|
</div>
|
||||||
|
<div className="lamp-row" style={{ width: "100%" }}>
|
||||||
<LampLine states={clock.oneMinute} rowTestId="single-minutes-row" />
|
<LampLine states={clock.oneMinute} rowTestId="single-minutes-row" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { dateToDigital, digitalToDate } from '../utils';
|
import { dateToDigital, digitalToDate } from "../utils";
|
||||||
|
|
||||||
interface DigitalClockProps {
|
interface DigitalClockProps {
|
||||||
enabled: boolean;
|
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
onTimeChange: (time: Date) => void;
|
onTimeChange: (time: Date) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DigitalClock({ enabled, currentTime, onTimeChange }: DigitalClockProps) {
|
export function DigitalClock({
|
||||||
|
currentTime,
|
||||||
|
onTimeChange,
|
||||||
|
disabled = false,
|
||||||
|
}: DigitalClockProps) {
|
||||||
const [inputValue, setInputValue] = useState(dateToDigital(currentTime));
|
const [inputValue, setInputValue] = useState(dateToDigital(currentTime));
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -19,17 +23,15 @@ export function DigitalClock({ enabled, currentTime, onTimeChange }: DigitalCloc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!enabled) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="digital-clock">{dateToDigital(currentTime)}</span>
|
|
||||||
<input
|
<input
|
||||||
data-testid="time-input"
|
data-testid="time-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="HH:MM:SS"
|
placeholder="HH:MM:SS"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } 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";
|
||||||
@@ -6,27 +6,39 @@ 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(true);
|
||||||
const enabled = !useSystemTime;
|
|
||||||
const berlinClock = dateToBerlin(currentTime);
|
const berlinClock = dateToBerlin(currentTime);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useSystemTime) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [useSystemTime, setCurrentTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="time-converter">
|
||||||
|
<BerlinClock enabled={true} clock={berlinClock} />
|
||||||
|
<DigitalClock
|
||||||
|
currentTime={currentTime}
|
||||||
|
onTimeChange={setCurrentTime}
|
||||||
|
disabled={useSystemTime}
|
||||||
|
/>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="use-system-time"
|
data-testid="use-system-time"
|
||||||
checked={useSystemTime}
|
checked={useSystemTime}
|
||||||
onChange={(e) => setUseSystemTime(e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setUseSystemTime(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
Use system time
|
Use system time
|
||||||
</label>
|
</label>
|
||||||
<BerlinClock enabled={enabled} clock={berlinClock} />
|
|
||||||
<DigitalClock
|
|
||||||
enabled={enabled}
|
|
||||||
currentTime={currentTime}
|
|
||||||
onTimeChange={setCurrentTime}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
|
|
||||||
export function useCurrentTime(): {
|
export function useCurrentTime(): {
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
--text: #6b6375;
|
||||||
--text-h: #08060d;
|
--text-h: #08060d;
|
||||||
@@ -55,10 +57,11 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,3 +112,26 @@ code {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--code-bg);
|
background: var(--code-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-container {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-container input {
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 28px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 16px auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-converter > :first-child {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
import '@testing-library/jest-dom';
|
import "@testing-library/jest-dom";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export enum LampState {
|
export enum LampState {
|
||||||
Y = 'Y', // Yellow
|
Y = "Y", // Yellow
|
||||||
R = 'R', // Red
|
R = "R", // Red
|
||||||
O = 'O', // Off
|
O = "O", // Off
|
||||||
N = 'N', // None / undefined
|
N = "N", // None / undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BerlinClock {
|
export interface BerlinClock {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { BerlinClock } from '../types';
|
import type { BerlinClock } from "../types";
|
||||||
import { LampState } from '../types';
|
import { LampState } from "../types";
|
||||||
|
|
||||||
export function dateToDigital(date: Date): string {
|
export function dateToDigital(date: Date): string {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function digitalToDate(digital: string): Date {
|
export function digitalToDate(digital: string): Date {
|
||||||
const parts = digital.split(':');
|
const parts = digital.split(":");
|
||||||
if (parts.length !== 3) return new Date(NaN);
|
if (parts.length !== 3) return new Date(NaN);
|
||||||
const [h, m, s] = parts.map(Number);
|
const [h, m, s] = parts.map(Number);
|
||||||
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
|
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
|
||||||
@@ -65,8 +65,7 @@ export function berlinToDate(berlin: BerlinClock): Date {
|
|||||||
arr.filter((s) => s === LampState.R || s === LampState.Y).length;
|
arr.filter((s) => s === LampState.R || s === LampState.Y).length;
|
||||||
|
|
||||||
const hours = countR(berlin.fiveHours) * 5 + countR(berlin.oneHour);
|
const hours = countR(berlin.fiveHours) * 5 + countR(berlin.oneHour);
|
||||||
const minutes =
|
const minutes = countYorR(berlin.fiveMinutes) * 5 + countY(berlin.oneMinute);
|
||||||
countYorR(berlin.fiveMinutes) * 5 + countY(berlin.oneMinute);
|
|
||||||
|
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(hours, minutes, 0, 0);
|
d.setHours(hours, minutes, 0, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user