Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintcache
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"/Users/edvinasbartkus/argyle/frontend-exercise/src/index.js":"1","/Users/edvinasbartkus/argyle/frontend-exercise/src/App.js":"2"},{"size":193,"mtime":1610055841961,"results":"3","hashOfConfig":"4"},{"size":197,"mtime":1610440606101,"results":"5","hashOfConfig":"4"},{"filePath":"6","messages":"7","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"10xp97u",{"filePath":"8","messages":"9","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/edvinasbartkus/argyle/frontend-exercise/src/index.js",[],"/Users/edvinasbartkus/argyle/frontend-exercise/src/App.js",[]]
[{"/Users/simonasjoncys/dev/frontend-exercise/src/index.js":"1","/Users/simonasjoncys/dev/frontend-exercise/src/App.js":"2","/Users/simonasjoncys/dev/frontend-exercise/src/naiveWordsToNumber.js":"3","/Users/simonasjoncys/dev/frontend-exercise/src/constants.js":"4","/Users/simonasjoncys/dev/frontend-exercise/src/validate.js":"5"},{"size":193,"mtime":1714573166474,"results":"6","hashOfConfig":"7"},{"size":1321,"mtime":1715598967698,"results":"8","hashOfConfig":"7"},{"size":2280,"mtime":1715602493114,"results":"9","hashOfConfig":"7"},{"size":572,"mtime":1715522922953,"results":"10","hashOfConfig":"7"},{"size":467,"mtime":1715532913371,"results":"11","hashOfConfig":"7"},{"filePath":"12","messages":"13","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"14"},"3lhv4j",{"filePath":"15","messages":"16","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"17","messages":"18","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"19","messages":"20","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"21","messages":"22","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/simonasjoncys/dev/frontend-exercise/src/index.js",[],["23","24"],"/Users/simonasjoncys/dev/frontend-exercise/src/App.js",[],"/Users/simonasjoncys/dev/frontend-exercise/src/naiveWordsToNumber.js",[],"/Users/simonasjoncys/dev/frontend-exercise/src/constants.js",[],"/Users/simonasjoncys/dev/frontend-exercise/src/validate.js",[],{"ruleId":"25","replacedBy":"26"},{"ruleId":"27","replacedBy":"28"},"no-native-reassign",["29"],"no-negated-in-lhs",["30"],"no-global-assign","no-unsafe-negation"]
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
# Frontend Exercise
There were some problems with `postcss` when trying to run the project as is. IDK why, I have no idea what dependency has that and why. A clean reinstall of deps helped, but altered the lock file.

# Solution

After giving this exercise a little bit of thought and since I'm currently reading [Grokking Algorithms](https://www.manning.com/books/grokking-algorithms), I 've elected to use a D&C algorithm to solve this.
* An AST based approach would probably be better suited, but the last time I've really delved deep into it was like 10 years ago. I would need to read up and brush up on the implementation. I'm happy to have a go at it if that's a required to pass to the latter stages of the process and you're willing to give me more time.
* I've elected to tokenize on the word boundaries (`<space>`)
* I've decided to divide along the periods. Which works well from the `thousand` and up, but leaves `hundred` a weird in-between nested period that needs to be handled recursively within the thousand periods. This is especially cumbersome when trying to generate good error messages without an AST.
* `twenty five hundred` works, which I find endearing and wanted to be the case. But so does `twenty five hundred thousand`, which is rarely spoken and not so much endearing. Changing this is trivial though, and would require to allow only a `DIGIT` to stand in front of a `hundred`

## Frontend Exercise

Currently, the app does not have any functionality, only the input element and a single paragraph with output. The goal is to cover the user stories list below:

Expand Down
46 changes: 38 additions & 8 deletions src/App.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,46 @@
import React from 'react'
import {naiveWordsToNumber} from "./naiveWordsToNumber";
import {validateInput} from "./validate";

function App() {
const text = ''
const [words, setWords] = React.useState('')
const [strictMode, setStrictMode] = React.useState(true)
const result = React.useMemo(() => {
const trimmed = words.trim()
if (trimmed === '') {
return {output: 0}
}
try {
if (strictMode) {
validateInput(trimmed)
}
const number = naiveWordsToNumber(trimmed)
return {
output: number.toString(), error: ''
}
} catch (e) {
return {
output: 'incorrect', error: e.message
}
}
}, [words, strictMode])

return (
<div>
<input type='text' />
return (<div>
<input aria-label="number-input" type='text' value={words}
onChange={event => setWords(event.currentTarget.value)}/>
<div>
<p>
Output: {text}
{result.error && (<p aria-label="error-message" style={{
color: 'red',
}}>{result.error}</p>)}
<p aria-label="result">
Output: {result.output}
</p>
<input id="strict" aria-label="strict-mode" type="checkbox" checked={strictMode} onChange={event => {
setStrictMode(event.target.checked)
}} />
<label htmlFor="strict">Strict mode</label>
</div>
</div>
)
</div>)
}

export default App
124 changes: 122 additions & 2 deletions src/App.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,125 @@
import {render, screen, fireEvent} from '@testing-library/react'
import App from "./App";

function numberInput(scope) {
const input = scope.getByLabelText('number-input')
return {
type: (value) => {
fireEvent.change(input, {target: {value}})
}
}
}

function strictModeCheckbox(scope) {
const checkbox = screen.getByLabelText('strict-mode')
const toggle = (checked) => {
const value = checkbox.checked
if ((!value && checked) || (value && !checked)) {
fireEvent(checkbox, new MouseEvent('click', {
bubbles: true, cancelable: true,
}))
}
}
return {
check: () => toggle(true),
uncheck: () => toggle(false)
}
}

function resultOutput(scope) {
const result = scope.getByLabelText('result')
return {
value: () => result.innerHTML,
numberValue: () => {
const match = /Output: (\d+)/.exec(result.innerHTML)
return (match || [])[1]
}
}
}

describe('App', () => {
it('should convert number to text', () => {
expect(true).toBe(false)
beforeEach(() => {
render(<App/>)
})

it('should convert "three" to number 3', () => {
numberInput(screen).type('three')
expect(resultOutput(screen).numberValue()).toBe('3')
})

it('should convert "fifty four" to number 54', () => {
numberInput(screen).type('fifty four')
expect(resultOutput(screen).numberValue()).toBe('54')
})

it('should convert "three hundred sixty seven" to number 367', () => {
numberInput(screen).type('three hundred sixty seven')
expect(resultOutput(screen).numberValue()).toBe('367')
})

it('should convert "sixty seven thousand nine hundred twelve" to number 67912', () => {
numberInput(screen).type('sixty seven thousand nine hundred twelve')
expect(resultOutput(screen).numberValue()).toBe('67912')
})

it('should convert "three million one hundred thousand and ninety" to number 3100090', () => {
numberInput(screen).type('three million one hundred thousand and ninety')
expect(resultOutput(screen).numberValue()).toBe('3100090')
})

it('should convert "nine hundred ninety nine million nine hundred nineteen thousand and nine hundred ninety nine" to 999919999', () => {
numberInput(screen).type('nine hundred ninety nine million nine hundred nineteen thousand and nine hundred ninety nine')
expect(resultOutput(screen).numberValue()).toBe('999919999')
})

it('should error on "nine thousand five thousand"', () => {
numberInput(screen).type('nine thousand five thousand')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should error on "twenty five hundred six hundred"', () => {
numberInput(screen).type('twenty five hundred six hundred')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should error on "five thousand two million"', () => {
numberInput(screen).type('five thousand two million')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should error on "one one"', () => {
numberInput(screen).type('one one')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should error on "eleven one"', () => {
numberInput(screen).type('eleven one')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should error on "asdasd" on strict mode', () => {
numberInput(screen).type('asdasd')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should pass on "asdasd" not in strict mode', () => {
strictModeCheckbox(screen).uncheck()
numberInput(screen).type('twenty asdasd two')
expect(resultOutput(screen).numberValue()).toBe('22')
})

it('should error on "hundred million"', () => {
numberInput(screen).type('hundred million')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should error on "million hundred"', () => {
numberInput(screen).type('million hundred')
expect(resultOutput(screen).value()).toBe('Output: incorrect')
})

it('should convert "one million" to number 1000000', () => {
numberInput(screen).type('one million')
expect(resultOutput(screen).numberValue()).toBe('1000000')
})
})
41 changes: 41 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const DIGIT = {
'one': 1,
'two': 2,
'three': 3,
'four': 4,
'five': 5,
'six': 6,
'seven': 7,
'eight': 8,
'nine': 9,
}

export const TEEN = {
'ten': 10,
'eleven': 11,
'twelve': 12,
'thirteen': 13,
'fourteen': 14,
'fifteen': 15,
'sixteen': 16,
'seventeen': 17,
'eighteen': 18,
'nineteen': 19,
}

export const TENS = {
'twenty': 20,
'thirty': 30,
'forty': 40,
'fourty': 40,
'fifty': 50,
'sixty': 60,
'seventy': 70,
'eighty': 80,
'ninety': 90,
}

export const PERIODS = {
'thousand': 1000,
'million': 1000000
}
97 changes: 97 additions & 0 deletions src/naiveWordsToNumber.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {DIGIT, TEEN, TENS, PERIODS} from './constants'

function tens(tokens) {
let ones = 0
let tens = 0

const l = tokens.length
for (let i = 0; i < l; i++) {
const token = tokens[i]

if (TENS[token]) {
if (tens > 0) {
throw new Error(`Encountered a redundant "${token}" in "${tokens.join(' ')}"`)
} else {
tens = TENS[token]
}
}

if (TEEN[token]) {
if (tens > 0 || ones > 0) {
throw new Error(`Encountered a redundant "${token}" in "${tokens.join(' ')}"`)

} else {
ones = TEEN[token]

}
}

if (DIGIT[token]) {
if (ones > 0) {
throw new Error(`Encountered a redundant "${token}" in "${tokens.join(' ')}"`)

} else {
ones = DIGIT[token]
}
}
}

return tens + ones
}

function hundreds(tokens) {
if (tokens.length === 1 && tokens[0] === 'hundred') {
throw new Error(`No place value / dangling "hundred"`)
}

const l = tokens.length
let delimiterIndex = 0
for (let i = 0; i < l; i++) {
const token = tokens[i].toLowerCase()
if (token === 'hundred') {
if (delimiterIndex > 0) {
throw new Error(`Encountered a redundant "hundred" in "${tokens.join(' ')}"`)
} else {
delimiterIndex = i
}
}
}

return tens(tokens.slice(0, delimiterIndex)) * 100 + tens(tokens.slice(delimiterIndex))
}

function period(tokens, period = undefined) {
if (tokens.indexOf(period) > -1) {
throw new Error(`Encountered a redundant "${period}" in "${tokens.join(' ')}"`)
}

return hundreds(tokens) * (PERIODS[period] || 1)
}


export function naiveWordsToNumber(string) {
const tokens = string.split(' ')

let sum = 0
const periodsProcessed = []
let accumulatedTokens = []

const l = tokens.length
for (let i = 0; i < l; i++) {
const token = tokens[i]
if (PERIODS[token]) {
if (periodsProcessed.some(p => p <= PERIODS[token])) {
throw new Error(`Encountered a redundant "${token}" in "${tokens.slice(0, i+1).join(' ')}"`)
}
periodsProcessed.push(PERIODS[token])
sum += period(accumulatedTokens, token)
accumulatedTokens = []
} else {
accumulatedTokens.push(token)
}
}

sum += period(accumulatedTokens)

return sum
}
22 changes: 22 additions & 0 deletions src/validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {DIGIT, PERIODS, TEEN, TENS} from "./constants";

export function validateInput(words) {
const validTokens = {
...DIGIT,
...TEEN,
...TENS,
...PERIODS,
'hundred': 100,
'and': 9999
}
const tokens = words.split(' ')
const l = tokens.length
for (let i = 0; i < l; i++) {
const token = tokens[i]
if (!validTokens[token]) {
throw new Error(`Encountered an invalid word "${token}"`)
}
}

return 42 // why not?
}
Loading