Moving OTP and Vin decoder to calculators to save Navbar space. Reworking calculators to purpose groups, and adding colors.

This commit is contained in:
Roland 2026-07-01 18:48:06 -06:00
parent c95afc7f62
commit 05bb65fc03
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 373 additions and 308 deletions

View file

@ -1,302 +1,20 @@
import React, {useContext, useState} from 'react';
import {
Checkbox,
Divider,
Icon,
Input,
Label,
Radio,
SegmentGroup,
StatisticLabel,
StatisticValue,
TableCell,
TableRow
} from "semantic-ui-react";
import React, {useContext} from 'react';
import {Input, Label, StatisticLabel, StatisticValue} from "semantic-ui-react";
import {Link, Route, Routes} from "react-router";
import {
appendChecksum,
decryptOTP,
encryptOTP,
formatMessage,
generateHtml,
OTP_CHARS,
OTP_CHARS_ALPHA,
stripChecksum,
validateCharset
} from "./otp";
import {
ErrorMessage,
humanFileSize,
humanNumber,
mimetypeColor,
PageContainer,
SimpleAccordion,
toLocaleString,
useTitle
} from "./Common";
import {ThemeContext} from "../contexts/contexts";
import {Button, Form, Header, Loader, Segment, Statistic, Table, TextArea} from "./Theme";
import {useStatistics, useVINDecoder} from "../hooks/customHooks";
import {Header, Loader, Segment, Statistic} from "./Theme";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
function Encrypt({chars, checksum}) {
// Encrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [plaintext, setPlaintext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setPlaintext(value);
}
};
let ciphertext = '';
let error = '';
if (otp && plaintext && chars) {
try {
const raw = encryptOTP(otp, plaintext, chars).ciphertext;
// When enabled, append a checksum character then re-group the whole message+checksum.
ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Encrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
<TextArea
name='plaintext'
className='otp'
value={plaintext}
onChange={handleInputChange}
placeholder='The message you want to send'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
function Decrypt({chars, checksum}) {
// Decrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [ciphertext, setCiphertext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setCiphertext(value);
}
};
let plaintext = '';
let error = '';
let checksumValid = null; // null = not applicable (checksum off or nothing to check)
if (otp && ciphertext && chars) {
try {
let input = ciphertext;
if (checksum) {
// Only remove the trailing character when it actually verifies as a checksum, so a message without
// one is never silently truncated.
const {body, valid} = stripChecksum(ciphertext, chars);
checksumValid = valid;
input = body;
}
plaintext = decryptOTP(otp, input, chars).plaintext;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Decrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
<TextArea
name='ciphertext'
className='otp'
value={ciphertext}
onChange={handleInputChange}
placeholder='The message you received'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
{checksumValid !== null && <p>
<Icon name={checksumValid ? 'check' : 'times'} color={checksumValid ? 'green' : 'red'}/>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</p>}
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
function OTP() {
useTitle('One Time Pad');
const {t} = useContext(ThemeContext);
// The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
const [customChars, setCustomChars] = useState('');
const charsetError = charsetKind === 'custom' ? validateCharset(customChars) : null;
const chars = charsetKind === 'alpha' ? OTP_CHARS_ALPHA
: charsetKind === 'custom' ? customChars
: OTP_CHARS;
// A null charset means the custom set is not yet usable; encrypt/decrypt/generate are gated on this.
const activeChars = charsetError ? null : chars;
// Optional error-detection checksum appended to the ciphertext.
const [checksum, setChecksum] = useState(false);
let cheatSheetURL = `${process.env.PUBLIC_URL}/one-time-pad-cheat-sheet.pdf`;
// Generate a new One-Time Pad entirely in the browser and open it in a new tab so it can be printed. The pad is
// never sent to or stored on the server.
const handleGenerateNewPad = () => {
const blob = new Blob([generateHtml(activeChars)], {type: 'text/html'});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Release the blob handle once the new tab has had time to load it.
setTimeout(() => URL.revokeObjectURL(url), 60000);
};
return <>
<Header as='h1'>One-Time Pad</Header>
<Header as='h4'>One-Time Pads can be used to encrypt your communications. This can be done by hand
(yes, really) or in this app.</Header>
<p {...t}>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>Generate New Pad</Button>
<Button secondary href={cheatSheetURL}>Cheat Sheet PDF</Button>
<SimpleAccordion title='Advanced'>
<Header as='h4'>Pad characters</Header>
<Form>
<Form.Field>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
</Form.Field>
<Form.Field>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
</Form.Field>
<Form.Field>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Form.Field>
</Form>
{charsetKind === 'custom' && <>
<Input
fluid
value={customChars}
placeholder='e.g. ABCDEF0123'
onChange={(e, {value}) => setCustomChars(value)}
/>
{charsetError && <ErrorMessage>{charsetError}</ErrorMessage>}
</>}
<Header as='h4'>Error detection</Header>
<Checkbox
label='Append error-detection checksum'
checked={checksum}
onChange={() => setChecksum(!checksum)}
/>
</SimpleAccordion>
<Divider/>
<Encrypt chars={activeChars} checksum={checksum}/>
<Divider/>
<Decrypt chars={activeChars} checksum={checksum}/>
</>
}
function VINDecoder() {
useTitle('VIN Number Decoder');
const {t} = useContext(ThemeContext);
const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
const {value, setValue, vin} = useVINDecoder();
console.debug(vin);
let body = <p {...t}>Enter a VIN number above</p>;
if (value && !vin) {
body = <p {...t}>VIN number is invalid</p>;
}
if (vin && vin['country']) {
let details = <p {...t}>No details</p>;
if (vin['body']) {
details = <Table celled columns={2} {...t}>
{detailsKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</TableCell>
</TableRow>)}
</Table>;
}
body = <>
<Table celled columns={2} {...t}>
{basicKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()]}</TableCell>
</TableRow>)}
</Table>
<Header as='h3'>Details</Header>
{details}
</>;
}
return <>
<Header as='h1'>VIN Number Decoder</Header>
<Input
size='large'
label='VIN'
value={value}
onChange={e => setValue(e.target.value)}
/>
<Header as='h2'>Decoded</Header>
{body}
</>
}
function StatisticsPage() {
useTitle('Statistics');
@ -423,9 +141,7 @@ export function MoreRoute(props) {
return <PageContainer>
<Routes>
<Route path='calculators' element={<CalculatorsPage/>}/>
<Route path='otp' exact element={<OTP/>}/>
<Route path='statistics' exact element={<StatisticsPage/>}/>
<Route path='vin' exact element={<VINDecoder/>}/>
</Routes>
</PageContainer>
}

View file

@ -1,6 +1,6 @@
import React from "react";
import {Breadcrumbs, useTitle} from "./Common";
import {Button, Icon} from "./Theme";
import {Button, Header, Icon} from "./Theme";
import {useCalcQuery} from "../hooks/customHooks";
import {TemperatureCalculator} from "./calculators/TemperatureCalculator";
import {ElectricalCalculators} from "./calculators/ElectricalCalculators";
@ -11,32 +11,63 @@ import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
import {DriveCalculator} from "./calculators/DriveCalculator";
import {WaterCalculator} from "./calculators/WaterCalculator";
import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
// Calculators are shown in these titled groups, in this order. Each calculator carries a
// `group` matching one of these names; any calculator whose group is missing/unknown falls
// into a trailing "Other" section so a new calculator can never silently disappear.
//
// `color` is a Semantic UI named color (colorblind-friendly palette) used to tint the group's
// buttons. Violet is intentionally avoided — it is the default navbar color and would blend in.
export const CALCULATOR_GROUPS = [
{name: 'Preparedness & Storage', color: 'red'},
{name: 'Radio & Communications', color: 'blue'},
{name: 'Engineering', color: 'yellow'},
{name: 'Tools & Reference', color: 'grey'},
];
export const useCalculators = () => {
const [calc, setCalc] = useCalcQuery();
const calculators = [
{key: 'ratio', icon: 'th large', button: 'Ratio', contents: <RatioCalculators/>},
{key: 'electrical', icon: 'lightning', button: 'Electrical', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: <DriveCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', contents: <DipoleAntennaCalculator/>},
{key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: <TemperatureCalculator/>},
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: <QRCodeCalculator/>},
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},
{key: 'temperature', icon: 'thermometer', button: 'Temperature', group: 'Tools & Reference', contents: <TemperatureCalculator/>},
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', group: 'Tools & Reference', contents: <QRCodeCalculator/>},
{key: 'otp', icon: 'lock', button: 'One Time Pad', title: 'One Time Pad', group: 'Radio & Communications', contents: <OneTimePadCalculator/>},
{key: 'vin', icon: 'car', button: 'VIN Decoder', title: 'VIN Decoder', group: 'Tools & Reference', contents: <VinDecoderCalculator/>},
];
const activeCalculator = calc ? calculators.find(i => i.key === calc) : null;
const calculatorLinks = calculators.map(i => {
const {button, icon, key} = i;
return <Link key={key} to={`/more/calculators?calc=${key}`}>
<Button key={key} style={{margin: '0.5em'}}>
const calculatorButton = ({button, icon, key}, color) =>
<Link key={key} to={`/more/calculators?calc=${key}`}>
<Button color={color} style={{margin: '0.5em'}}>
{icon && <Icon name={icon}/>}
{button}
</Button>
</Link>
});
</Link>;
// Render each defined group as a titled section, then any leftovers under "Other".
const groupNames = CALCULATOR_GROUPS.map(g => g.name);
const grouped = CALCULATOR_GROUPS.map(g => [g.name, g.color, calculators.filter(c => c.group === g.name)]);
const leftovers = calculators.filter(c => !groupNames.includes(c.group));
if (leftovers.length > 0) {
grouped.push(['Other', undefined, leftovers]);
}
const calculatorLinks = grouped
.filter(([, , items]) => items.length > 0)
.map(([name, color, items]) =>
<div key={name} style={{marginBottom: '1.5em'}}>
<Header as='h3'>{name}</Header>
{items.map(c => calculatorButton(c, color))}
</div>);
return {calc, calculators, activeCalculator, calculatorLinks}
}

View file

@ -56,10 +56,8 @@ const allLinks = [
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
{text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/otp', text: 'One Time Pad', key: 'otp', end: true},
{to: '/more/vin', text: 'VIN Decoder', key: 'vin', end: true},
{to: '/more/calculators', text: 'Calculators', key: 'calculators', end: true},
{text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/statistics', text: 'Statistics', key: 'statistics', end: true},
];

View file

@ -29,9 +29,13 @@ const SUGGESTED_APPS = [
{location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'},
{location: '/map', title: 'Map', description: 'View your Map'},
{location: '/map/manage', title: 'Manage Map', description: 'Manage your Map'},
{location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/calculators?calc=otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/statistics', title: 'Statistics', description: 'View the statistics of your WROLPi'},
{location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'},
{
location: '/more/calculators?calc=vin',
title: 'Vin Decoder',
description: 'Decode and analyze vehicle VIN numbers'
},
{location: '/videos', title: 'Videos', description: 'View your Videos'},
{location: '/videos/channel', title: 'Channels', description: 'View the Channels of your Videos'},
{location: '/zim', title: 'Zim', description: 'View your Zims'},

View file

@ -0,0 +1,221 @@
import React, {useContext, useState} from 'react';
import {Checkbox, Divider, Icon, Input, Radio, SegmentGroup} from "semantic-ui-react";
import {
appendChecksum,
decryptOTP,
encryptOTP,
formatMessage,
generateHtml,
OTP_CHARS,
OTP_CHARS_ALPHA,
stripChecksum,
validateCharset
} from "../otp";
import {ErrorMessage, SimpleAccordion} from "../Common";
import {ThemeContext} from "../../contexts/contexts";
import {Button, Form, Header, Segment, TextArea} from "../Theme";
function Encrypt({chars, checksum}) {
// Encrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [plaintext, setPlaintext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setPlaintext(value);
}
};
let ciphertext = '';
let error = '';
if (otp && plaintext && chars) {
try {
const raw = encryptOTP(otp, plaintext, chars).ciphertext;
// When enabled, append a checksum character then re-group the whole message+checksum.
ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Encrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
<TextArea
name='plaintext'
className='otp'
value={plaintext}
onChange={handleInputChange}
placeholder='The message you want to send'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
function Decrypt({chars, checksum}) {
// Decrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [ciphertext, setCiphertext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setCiphertext(value);
}
};
let plaintext = '';
let error = '';
let checksumValid = null; // null = not applicable (checksum off or nothing to check)
if (otp && ciphertext && chars) {
try {
let input = ciphertext;
if (checksum) {
// Only remove the trailing character when it actually verifies as a checksum, so a message without
// one is never silently truncated.
const {body, valid} = stripChecksum(ciphertext, chars);
checksumValid = valid;
input = body;
}
plaintext = decryptOTP(otp, input, chars).plaintext;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Decrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
<TextArea
name='ciphertext'
className='otp'
value={ciphertext}
onChange={handleInputChange}
placeholder='The message you received'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
{checksumValid !== null && <p>
<Icon name={checksumValid ? 'check' : 'times'} color={checksumValid ? 'green' : 'red'}/>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</p>}
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
export function OneTimePadCalculator() {
const {t} = useContext(ThemeContext);
// The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
const [customChars, setCustomChars] = useState('');
const charsetError = charsetKind === 'custom' ? validateCharset(customChars) : null;
const chars = charsetKind === 'alpha' ? OTP_CHARS_ALPHA
: charsetKind === 'custom' ? customChars
: OTP_CHARS;
// A null charset means the custom set is not yet usable; encrypt/decrypt/generate are gated on this.
const activeChars = charsetError ? null : chars;
// Optional error-detection checksum appended to the ciphertext.
const [checksum, setChecksum] = useState(false);
let cheatSheetURL = `${process.env.PUBLIC_URL}/one-time-pad-cheat-sheet.pdf`;
// Generate a new One-Time Pad entirely in the browser and open it in a new tab so it can be printed. The pad is
// never sent to or stored on the server.
const handleGenerateNewPad = () => {
const blob = new Blob([generateHtml(activeChars)], {type: 'text/html'});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Release the blob handle once the new tab has had time to load it.
setTimeout(() => URL.revokeObjectURL(url), 60000);
};
return <>
<Header as='h1'>One-Time Pad</Header>
<Header as='h4'>One-Time Pads can be used to encrypt your communications. This can be done by hand
(yes, really) or in this app.</Header>
<p {...t}>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>Generate New Pad</Button>
<Button secondary href={cheatSheetURL}>Cheat Sheet PDF</Button>
<SimpleAccordion title='Advanced'>
<Header as='h4'>Pad characters</Header>
<Form>
<Form.Field>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
</Form.Field>
<Form.Field>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
</Form.Field>
<Form.Field>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Form.Field>
</Form>
{charsetKind === 'custom' && <>
<Input
fluid
value={customChars}
placeholder='e.g. ABCDEF0123'
onChange={(e, {value}) => setCustomChars(value)}
/>
{charsetError && <ErrorMessage>{charsetError}</ErrorMessage>}
</>}
<Header as='h4'>Error detection</Header>
<Checkbox
label='Append error-detection checksum'
checked={checksum}
onChange={() => setChecksum(!checksum)}
/>
</SimpleAccordion>
<Divider/>
<Encrypt chars={activeChars} checksum={checksum}/>
<Divider/>
<Decrypt chars={activeChars} checksum={checksum}/>
</>
}

View file

@ -0,0 +1,28 @@
import React from "react";
import {fireEvent, screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {OneTimePadCalculator} from "./OneTimePadCalculator";
describe('OneTimePadCalculator', () => {
test('renders the pad tools', () => {
renderWithProviders(<OneTimePadCalculator/>);
expect(screen.getByText('One-Time Pad')).toBeTruthy();
expect(screen.getByText('Generate New Pad')).toBeTruthy();
expect(screen.getByText('Encrypt')).toBeTruthy();
expect(screen.getByText('Decrypt')).toBeTruthy();
});
test('encrypts live using the entered key', () => {
const {container} = renderWithProviders(<OneTimePadCalculator/>);
// The Encrypt section's Key and Plaintext textareas are the first two on the page.
const keys = container.querySelectorAll('textarea[name="otp"]');
const plaintext = container.querySelector('textarea[name="plaintext"]');
fireEvent.change(keys[0], {target: {value: 'ABCDE'}});
fireEvent.change(plaintext, {target: {value: 'HELLO'}});
// Ciphertext is rendered into a <pre> and differs from the plaintext once a key is present.
const pres = container.querySelectorAll('pre');
const ciphertext = pres[0].textContent;
expect(ciphertext).toBeTruthy();
expect(ciphertext).not.toEqual('HELLO');
});
});

View file

@ -0,0 +1,53 @@
import React, {useContext} from 'react';
import {Input, TableCell, TableRow} from "semantic-ui-react";
import {ThemeContext} from "../../contexts/contexts";
import {useVINDecoder} from "../../hooks/customHooks";
import {Header, Table} from "../Theme";
export function VinDecoderCalculator() {
const {t} = useContext(ThemeContext);
const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
const {value, setValue, vin} = useVINDecoder();
let body = <p {...t}>Enter a VIN number above</p>;
if (value && !vin) {
body = <p {...t}>VIN number is invalid</p>;
}
if (vin && vin['country']) {
let details = <p {...t}>No details</p>;
if (vin['body']) {
details = <Table celled columns={2} {...t}>
{detailsKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</TableCell>
</TableRow>)}
</Table>;
}
body = <>
<Table celled columns={2} {...t}>
{basicKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()]}</TableCell>
</TableRow>)}
</Table>
<Header as='h3'>Details</Header>
{details}
</>;
}
return <>
<Header as='h1'>VIN Number Decoder</Header>
<Input
size='large'
label='VIN'
value={value}
onChange={e => setValue(e.target.value)}
/>
<Header as='h2'>Decoded</Header>
{body}
</>
}

View file

@ -0,0 +1,14 @@
import React from "react";
import {screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {VinDecoderCalculator} from "./VinDecoderCalculator";
describe('VinDecoderCalculator', () => {
test('renders the decoder and prompts for input', () => {
// An empty VIN never triggers an API call (useVINDecoder returns early), so this
// renders without any fetch mocking.
renderWithProviders(<VinDecoderCalculator/>);
expect(screen.getByText('VIN Number Decoder')).toBeTruthy();
expect(screen.getByText('Enter a VIN number above')).toBeTruthy();
});
});