From f24edd3748470270e9b6f7700c9e1b09cab37ecb Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 22 Jun 2024 21:28:17 -0700 Subject: [PATCH] refactor: Extract binary digits logic The "business logic" for determining the digits corresponding to the current time was closely coupled to the rendering logic. This commit extracts the digit computation logic, primarily to facilitate unit testing, but it has the side benefit of making the code easier to read. --- __tests__/App-test.tsx | 16 --- package.json | 1 + src/components/BinaryClock.tsx | 65 +++------ src/components/BinaryDigit.tsx | 27 +--- src/components/BinaryDot.tsx | 17 +-- .../__fixtures__/toRepresentBinary.ts | 94 +++++++++++++ src/utils/__tests__/binaryTime.test.ts | 110 +++++++++++++++ src/utils/binaryTime.ts | 131 ++++++++++++++++++ src/utils/useTime.ts | 18 +++ yarn.lock | 12 +- 10 files changed, 396 insertions(+), 95 deletions(-) delete mode 100644 __tests__/App-test.tsx create mode 100644 src/utils/__tests__/__fixtures__/toRepresentBinary.ts create mode 100644 src/utils/__tests__/binaryTime.test.ts create mode 100644 src/utils/binaryTime.ts create mode 100644 src/utils/useTime.ts diff --git a/__tests__/App-test.tsx b/__tests__/App-test.tsx deleted file mode 100644 index 72b8b5b..0000000 --- a/__tests__/App-test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @format - */ - -import 'react-native'; - -import App from '../App'; -import React from 'react'; -// Note: import explicitly to use the types shipped with jest. -import {it} from '@jest/globals'; -// Note: test renderer must be required after react-native. -import renderer from 'react-test-renderer'; - -it('renders correctly', () => { - renderer.create(); -}); diff --git a/package.json b/package.json index 76e8881..3237727 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@rnx-kit/cli": "^0.16.8", "@rnx-kit/metro-config": "^1.3.6", "@rnx-kit/metro-resolver-symlinks": "^0.1.28", + "@types/jest": "^29.5.12", "@types/react": "^18.2.6", "@types/react-native": "^0.67.3", "@types/react-native-keep-awake": "^2.0.3", diff --git a/src/components/BinaryClock.tsx b/src/components/BinaryClock.tsx index 92a2fed..8a1b60a 100644 --- a/src/components/BinaryClock.tsx +++ b/src/components/BinaryClock.tsx @@ -1,14 +1,15 @@ -// Copyright (c) 2022 Joseph Hale +// Copyright (c) 2022-2024 Joseph Hale // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -import React, {useEffect, useState} from 'react'; +import {BinaryTimeMode, asBinaryTime} from '../utils/binaryTime'; import {StyleSheet, View} from 'react-native'; import BinaryDigit from './BinaryDigit'; import Orientation from '../utils/orientation'; +import {useTime} from '../utils/useTime'; interface BinaryClockProps { orientation?: Orientation; @@ -26,48 +27,25 @@ const DEFAULTS = { const BinaryClock: React.FC = args => { const props = {...DEFAULTS, ...args}; - - const [time, setTime] = useState(new Date()); - useEffect(() => { - const toggle = setInterval(() => { - setTime(new Date()); - }, 50); - return () => clearInterval(toggle); - }); - - let digits = []; - let hours = time.getHours(); - let minutes = time.getMinutes(); - let seconds = time.getSeconds(); - switch (props.orientation) { - case Orientation.Portrait: - digits = [ - {key: 'h', value: hours, maxVisible: 23}, - {key: 'm', value: minutes, maxVisible: 59}, - {key: 's', value: seconds, maxVisible: 59}, - ]; - break; - case Orientation.Landscape: - digits = [ - {key: 'h10', value: firstDigit(hours), maxVisible: 2}, - {key: 'h1', value: secondDigit(hours), maxVisible: 9}, - {key: 'm10', value: firstDigit(minutes), maxVisible: 5}, - {key: 'm1', value: secondDigit(minutes), maxVisible: 9}, - {key: 's10', value: firstDigit(seconds), maxVisible: 5}, - {key: 's1', value: secondDigit(seconds), maxVisible: 9}, - ]; - break; - } - let maxValue = Math.max(...digits.map(digit => digit.maxVisible)); + const time = useTime(); + const binaryTime = asBinaryTime( + { + hours: time.getHours(), + minutes: time.getMinutes(), + seconds: time.getSeconds(), + }, + { + [Orientation.Portrait]: BinaryTimeMode.SINGLE_DIGITS, + [Orientation.Landscape]: BinaryTimeMode.DOUBLE_DIGITS, + }[props.orientation], + ); return ( - {digits.map(digit => ( + {binaryTime.digits.map((digit, idx) => ( = args => { ); }; -function firstDigit(value: number) { - return Math.floor(value / 10); -} -function secondDigit(value: number) { - return value % 10; -} - const styles = StyleSheet.create({ binaryClock: { flexDirection: 'row', diff --git a/src/components/BinaryDigit.tsx b/src/components/BinaryDigit.tsx index e194291..eacc1c8 100644 --- a/src/components/BinaryDigit.tsx +++ b/src/components/BinaryDigit.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Joseph Hale +// Copyright (c) 2022-2024 Joseph Hale // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,49 +8,36 @@ import {StyleSheet, Text, View} from 'react-native'; import BinaryDot from './BinaryDot'; import React from 'react'; +import { type BinaryDigit as BinaryDigitType } from '../utils/binaryTime'; interface BinaryDigitProps { - value: number; + digit: BinaryDigitType brightness?: number; roundness?: number; - maxVisible?: number; - maxValue?: number; showHints?: boolean; } const DEFAULTS = { brightness: 1, roundness: 1, - maxVisible: 15, - maxValue: 15, showHints: false, }; -/* eslint no-bitwise: ["error", { "allow": ["&"] }] */ const BinaryDigit: React.FC = args => { const props = {...DEFAULTS, ...args}; - - let dotCount = 0; - if (props.maxValue > 0) { - dotCount = Math.floor(Math.log2(props.maxValue)) + 1; - } - let dotValues = Array.from(Array(dotCount), (_, i) => 2 ** i); - return ( - {dotValues.reverse().map(value => ( + {props.digit.bits.map((bit, idx) => ( 0} - visible={props.maxVisible >= value} - value={value} + key={idx} + bit={bit} brightness={props.brightness} roundness={props.roundness} showHints={props.showHints} /> ))} - {props.showHints && {props.value}} + {props.showHints && {props.digit.value}} ); diff --git a/src/components/BinaryDot.tsx b/src/components/BinaryDot.tsx index 587c518..a008c9b 100644 --- a/src/components/BinaryDot.tsx +++ b/src/components/BinaryDot.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Joseph Hale +// Copyright (c) 2022-2024 Joseph Hale // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -6,20 +6,17 @@ import {StyleSheet, Text, View, ViewStyle} from 'react-native'; +import {BinaryBit} from '../utils/binaryTime'; import React from 'react'; interface BinaryDotProps { - active: boolean; - visible?: boolean; - value?: number; + bit: BinaryBit; brightness?: number; roundness?: number; showHints?: boolean; } const DEFAULTS = { - visible: true, - value: 1, brightness: 1, roundness: 1, showHints: false, @@ -30,8 +27,8 @@ const FULL_ROUNDNESS_RADIUS = 30; const BinaryDot: React.FC = args => { const props = {...DEFAULTS, ...args}; - let active_modifier = props.active ? 1 : 0.25; - let visible_modifier = props.visible ? 1 : 0; + let active_modifier = props.bit.active ? 1 : 0.25; + let visible_modifier = props.bit.visible ? 1 : 0; const overrides: ViewStyle = { opacity: props.brightness * active_modifier * visible_modifier, borderRadius: props.roundness * FULL_ROUNDNESS_RADIUS, @@ -39,9 +36,9 @@ const BinaryDot: React.FC = args => { return ( - {props.showHints && props.value && ( + {props.showHints && props.bit.value && ( - {props.value} + {props.bit.value} )} diff --git a/src/utils/__tests__/__fixtures__/toRepresentBinary.ts b/src/utils/__tests__/__fixtures__/toRepresentBinary.ts new file mode 100644 index 0000000..588a941 --- /dev/null +++ b/src/utils/__tests__/__fixtures__/toRepresentBinary.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2024 Joseph Hale +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import {type BinaryBit, type BinaryDigit} from '../../binaryTime'; + +declare module 'expect' { + interface AssymmetricMatchers { + toRepresentBinary(templates: string | string[]): void + } + interface Matchers { + toRepresentBinary(templates: string | string[]): R + } +} +expect.extend({toRepresentBinary}); + +function toRepresentBinary( + actual: BinaryDigit | BinaryDigit[], + templates: string | string[], +) { + const _digits = Array.isArray(actual) ? actual : [actual]; + const _templates = Array.isArray(templates) ? templates : [templates]; + if (_digits.length != _templates.length) { + return { + message: () => + `expected: ${_templates.length} digits for '${JSON.stringify( + _templates, + )}', was: ${_digits.length} digits in ${JSON.stringify(_digits)}`, + pass: false, + }; + } + for (let idx = 0; idx < _templates.length; idx++) { + const result = _toRepresentBinary(_digits[idx].bits, _templates[idx]); + if (result) return result; + } + return { + message: () => `expected '${_digits}' to represent '${_templates}'`, + pass: true, + }; +} +function _toRepresentBinary(actual: BinaryBit[], template: string) { + const _bits = actual; + if (_bits.length != template.length) { + return { + message: () => + `expected: ${template.length} bits for '${template}', was: ${ + _bits.length + } bits in ${JSON.stringify(_bits)}`, + pass: false, + }; + } else { + const msg = (idx: number) => + `for template '${template}', bit ${idx + 1}/${_bits.length} in ${_bits}`; + for (let idx = 0; idx < template.length; idx++) { + switch (template[idx]) { + case ' ': + if (visibility(_bits[idx]) != 'invisible') { + return {message: () => msg(idx), pass: false}; + } + break; + case '0': + if (activeStatus(_bits[idx]) != 'inactive') { + return {message: () => msg(idx), pass: false}; + } + if (visibility(_bits[idx]) != 'visible') { + return {message: () => msg(idx), pass: false}; + } + break; + case '1': + if (activeStatus(_bits[idx]) != 'active') { + return {message: () => msg(idx), pass: false}; + } + if (visibility(_bits[idx]) != 'visible') { + return {message: () => msg(idx), pass: false}; + } + break; + default: + throw new Error( + `A binary template cannot contain '${template[idx]}'`, + ); + } + } + } +} + +function activeStatus(bit: BinaryBit): string { + return bit.active ? 'active' : 'inactive'; +} + +function visibility(bit: BinaryBit): string { + return bit.visible ? 'visible' : 'invisible'; +} diff --git a/src/utils/__tests__/binaryTime.test.ts b/src/utils/__tests__/binaryTime.test.ts new file mode 100644 index 0000000..4435f81 --- /dev/null +++ b/src/utils/__tests__/binaryTime.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2024 Joseph Hale +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import './__fixtures__/toRepresentBinary'; + +import { + BinaryTimeMode, + InvalidBinaryConversionException, + asBinaryDigit, + asBinaryTime, +} from '../binaryTime'; +import {describe, expect, test} from '@jest/globals'; + +describe('Time to Binary Time (Single Digits)', () => { + test('midnight is all zeros', () => { + const time = asBinaryTime({hours: 0, minutes: 0, seconds: 0}); + expect(time.digits).toRepresentBinary([' 00000', '000000', '000000']); + }); + test('just before midnight converts correctly', () => { + const time = asBinaryTime({hours: 23, minutes: 59, seconds: 59}); + expect(time.digits).toRepresentBinary([' 10111', '111011', '111011']); + }); +}); + +describe('Time to Binary Time (Double Digits)', () => { + test('midnight is all zeros', () => { + const time = asBinaryTime( + {hours: 0, minutes: 0, seconds: 0}, + BinaryTimeMode.DOUBLE_DIGITS, + ); + expect(time.digits).toRepresentBinary([ + ' 00', + '0000', + ' 000', + '0000', + ' 000', + '0000', + ]); + }); + test('just before midnight converts correctly', () => { + const time = asBinaryTime( + {hours: 23, minutes: 59, seconds: 59}, + BinaryTimeMode.DOUBLE_DIGITS, + ); + expect(time.digits).toRepresentBinary([ + ' 10', + '0011', + ' 101', + '1001', + ' 101', + '1001', + ]); + }); +}); + +describe('Number to Binary Digit', () => { + test(`negatives are not supported`, () => { + expect(() => asBinaryDigit(-1)).toThrowError( + InvalidBinaryConversionException, + ); + }); + test(`digits are aware of their value`, () => { + const digit = asBinaryDigit(32); + expect(digit.value).toEqual(32); + }); + test(`numbers return the minimum bits required`, () => { + expect(asBinaryDigit(0)).toRepresentBinary('0'); + expect(asBinaryDigit(1)).toRepresentBinary('1'); + expect(asBinaryDigit(2)).toRepresentBinary('10'); + expect(asBinaryDigit(4)).toRepresentBinary('100'); + expect(asBinaryDigit(8)).toRepresentBinary('1000'); + expect(asBinaryDigit(16)).toRepresentBinary('10000'); + expect(asBinaryDigit(32)).toRepresentBinary('100000'); + expect(asBinaryDigit(64)).toRepresentBinary('1000000'); + }); + test(`each bit is aware of its value within the larger digit`, () => { + const bits = asBinaryDigit(59).bits; + expect(bits[0].value).toEqual(32); + expect(bits[1].value).toEqual(16); + expect(bits[2].value).toEqual(8); + expect(bits[3].value).toEqual(4); + expect(bits[4].value).toEqual(2); + expect(bits[5].value).toEqual(1); + }); + test(`providing a large max value pre-pads the digit with zeros`, () => { + const digit = asBinaryDigit(15, {maxValue: 59}); + expect(digit).toRepresentBinary('001111'); + }); + test(`max value must equal or exceed digit value`, () => { + const digit = asBinaryDigit(15, {maxValue: 15}); + expect(digit).toRepresentBinary('1111'); + expect(() => asBinaryDigit(15, {maxValue: 14})).toThrowError( + InvalidBinaryConversionException, + ); + }); + test(`max visible value hides bits larger than the threshold`, () => { + const digit = asBinaryDigit(15, {maxValue: 59, maxVisibleValue: 24}); + expect(digit).toRepresentBinary(' 01111'); + }); + test(`max visible value must equal or exceed digit value`, () => { + const digit = asBinaryDigit(15, {maxVisibleValue: 15}); + expect(digit).toRepresentBinary('1111'); + expect(() => asBinaryDigit(15, {maxVisibleValue: 14})).toThrowError( + InvalidBinaryConversionException, + ); + }); +}); diff --git a/src/utils/binaryTime.ts b/src/utils/binaryTime.ts new file mode 100644 index 0000000..c501672 --- /dev/null +++ b/src/utils/binaryTime.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2024 Joseph Hale +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +const {ceil, log2, pow, max, floor} = Math; + +export class InvalidBinaryConversionException extends Error {} + +export interface BinaryBit { + active: boolean; + value: number; + visible: boolean; +} + +export interface BinaryDigit { + /** + * The bits comprising the digit in descending order of value. + * + * "12" (base 10) -> "1010" (base 2) -> { 1, 0, 1, 0 } (collection order) + */ + bits: BinaryBit[]; + value: number; +} + +interface BinaryTime { + digits: BinaryDigit[]; +} + +interface Time { + hours: number; + minutes: number; + seconds: number; +} + +export enum BinaryTimeMode { + /** Each time component is represented by a single digit */ + SINGLE_DIGITS = "SINGLE_DIGITS", + + /** Each time component is represented by a two digits (tens and ones places) */ + DOUBLE_DIGITS = "DOUBLE_DIGITS", +} + +export function asBinaryTime( + time: Time, + mode: BinaryTimeMode = BinaryTimeMode.SINGLE_DIGITS, +): BinaryTime { + switch (mode) { + case BinaryTimeMode.SINGLE_DIGITS: + return { + digits: [ + asBinaryDigit(time.hours, {maxVisibleValue: 23, maxValue: 59}), + asBinaryDigit(time.minutes, {maxVisibleValue: 59, maxValue: 59}), + asBinaryDigit(time.seconds, {maxVisibleValue: 59, maxValue: 59}), + ], + }; + + case BinaryTimeMode.DOUBLE_DIGITS: + const firstDigit = (n: number) => Math.floor(n / 10); + const secondDigit = (n: number) => n % 10; + return { + digits: [ + asBinaryDigit(firstDigit(time.hours), { + maxVisibleValue: 2, + maxValue: 9, + }), + asBinaryDigit(secondDigit(time.hours), { + maxVisibleValue: 9, + maxValue: 9, + }), + asBinaryDigit(firstDigit(time.minutes), { + maxVisibleValue: 5, + maxValue: 9, + }), + asBinaryDigit(secondDigit(time.minutes), { + maxVisibleValue: 9, + maxValue: 9, + }), + asBinaryDigit(firstDigit(time.seconds), { + maxVisibleValue: 5, + maxValue: 9, + }), + asBinaryDigit(secondDigit(time.seconds), { + maxVisibleValue: 9, + maxValue: 9, + }), + ], + }; + } +} + +export function asBinaryDigit( + number: number, + options?: { + maxValue?: number; + maxVisibleValue?: number; + }, +): BinaryDigit { + const _maxValue = options?.maxValue ?? number; + const _maxVisibleValue = + options?.maxVisibleValue ?? ceilingPowerOf2(_maxValue); + if (number < 0) { + throw new InvalidBinaryConversionException(`${number} must be positive`); + } else if (_maxValue < number) { + throw new InvalidBinaryConversionException( + `A max value of ${_maxValue} would truncate ${number}`, + ); + } else if (_maxVisibleValue < number) { + throw new InvalidBinaryConversionException( + `A max visible value of ${_maxVisibleValue} would truncate ${number}`, + ); + } else { + const bitCount = max(1, floor(log2(_maxValue)) + 1); + const bitValues = Array.from(Array(bitCount), (_, i) => 2 ** i).reverse(); + return { + bits: bitValues.map(i => ({ + active: (number & i) > 0, + value: i, + visible: + _maxVisibleValue >= i || (number == 0 && _maxVisibleValue == 0), + })), + value: number, + }; + } +} + +function ceilingPowerOf2(number: number): number { + const exponent = ceil(log2(number)); + return pow(2.0, exponent); +} diff --git a/src/utils/useTime.ts b/src/utils/useTime.ts new file mode 100644 index 0000000..8247a8f --- /dev/null +++ b/src/utils/useTime.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2024 Joseph Hale +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import {useEffect, useState} from 'react'; + +type Milliseconds = number; + +export function useTime(refreshRate: Milliseconds = 50) { + const [time, setTime] = useState(new Date()); + useEffect(() => { + const toggle = setInterval(() => setTime(new Date()), refreshRate); + return () => clearInterval(toggle); + }); + return time; +} diff --git a/yarn.lock b/yarn.lock index 8fa702e..5514a5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2416,6 +2416,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@^29.5.12": + version "29.5.12" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" + integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -4131,7 +4139,7 @@ expand-template@^2.0.3: resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== -expect@^29.7.0: +expect@^29.0.0, expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== @@ -6367,7 +6375,7 @@ pretty-format@^26.5.2, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" -pretty-format@^29.7.0: +pretty-format@^29.0.0, pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==