improve docs, black formatting
This commit is contained in:
parent
6488ea0ede
commit
df11a33a54
148
README.md
148
README.md
@ -1,122 +1,52 @@
|
|||||||
# Turner & Townsend backend assessment
|
# Turner & Townsend backend technical assessment
|
||||||
|
|
||||||
These programs need Python 3.10+ and pytest.
|
This repository contains my solutions to the Turner & Townsend backend technical assessment. While the brief requested solving only one problem, I started with the "spiciest" Stack task for the first hour and then tackled the other two tasks in the second hour. I did come back the next day and spend some extra time improving the README documentation.
|
||||||
|
|
||||||
Original assessment: https://github.com/turner-townsend/backend-assessment
|
## Solutions
|
||||||
|
|
||||||
## The Collatz Conjecture
|
### [Stack Calculator](./stack/README.md)
|
||||||
|
|
||||||
Takes numeric input and calculates the number of steps in the Collatz sequence needed to reach 1
|
**Complexity: Medium**
|
||||||
|
|
||||||
## Usage
|
A stack-based programming language interpreter (Fifth) that supports arithmetic operations and stack manipulation commands. Features an interactive REPL with comprehensive error handling.
|
||||||
|
|
||||||
```shell
|
### [Roman Numerals Converter](./roman/README.md)
|
||||||
python collatz/collatz.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example
|
**Complexity: Medium**
|
||||||
|
|
||||||
```
|
Converts Roman numerals to Arabic numbers using additive logic. Includes flexible input handling with case-insensitive processing and automatic filtering of invalid characters.
|
||||||
Please enter a whole number: 1
|
|
||||||
Result: 0 steps needed to reach 1
|
|
||||||
Please enter a whole number: 3
|
|
||||||
Step #1: odd, multiply by 3 and add 1 -> 10
|
|
||||||
Step #2: even, divide by 2 -> 5
|
|
||||||
Step #3: odd, multiply by 3 and add 1 -> 16
|
|
||||||
Step #4: even, divide by 2 -> 8
|
|
||||||
Step #5: even, divide by 2 -> 4
|
|
||||||
Step #6: even, divide by 2 -> 2
|
|
||||||
Step #7: even, divide by 2 -> 1
|
|
||||||
Result: 8 steps needed to reach 3
|
|
||||||
Please enter a whole number: 0
|
|
||||||
Result: 0 steps needed to reach 0
|
|
||||||
Please enter a whole number: !£$
|
|
||||||
ERROR: integer required
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
### [Collatz Conjecture Calculator](./collatz/README.md)
|
||||||
|
|
||||||
|
**Complexity: Low**
|
||||||
|
|
||||||
|
Calculates the number of steps in the Collatz sequence (3n + 1 problem) with step-by-step visualization. Demonstrates mathematical algorithm implementation with interactive feedback.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All three solutions follow consistent design principles:
|
||||||
|
|
||||||
|
- Class-based structure for encapsulation and testability
|
||||||
|
- Interactive command-line interfaces with graceful error handling
|
||||||
|
- Comprehensive test suites with good coverage
|
||||||
|
- Modern Python features and best practices
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**Requirements:** Python 3.10+, pytest
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest collatz
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run solutions
|
||||||
|
cd stack && python stack.py
|
||||||
|
cd roman && python roman.py
|
||||||
|
cd collatz && python collatz.py
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest # All tests
|
||||||
|
pytest stack/ # Individual solution tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roman Numerals
|
Click the links above to view detailed documentation for each solution.
|
||||||
|
|
||||||
A simple Roman numerals number converter
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```shell
|
|
||||||
python roman/roman.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```
|
|
||||||
Please enter some Roman numerals: I
|
|
||||||
I = 1
|
|
||||||
Please enter some Roman numerals: IV
|
|
||||||
IV = 1 + 5 = 6
|
|
||||||
Please enter some Roman numerals: MCMXCIV
|
|
||||||
MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216
|
|
||||||
Please enter some Roman numerals: !£$
|
|
||||||
ERROR: Invalid input
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest roman
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fifth Stack
|
|
||||||
|
|
||||||
A simple stack-based language called Fifth
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```shell
|
|
||||||
python stack/stack.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
- `PUSH <n>` - push integer onto stack
|
|
||||||
- `POP` - remove top element
|
|
||||||
- `SWAP` - swap top two elements
|
|
||||||
- `DUP` - duplicate top element
|
|
||||||
- `+`, `-`, `*`, `/` - arithmetic operations
|
|
||||||
- `EXIT` - quit
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```
|
|
||||||
stack is []
|
|
||||||
PUSH 3
|
|
||||||
stack is [3]
|
|
||||||
PUSH 11
|
|
||||||
stack is [3, 11]
|
|
||||||
+
|
|
||||||
stack is [14]
|
|
||||||
DUP
|
|
||||||
stack is [14, 14]
|
|
||||||
PUSH 2
|
|
||||||
stack is [14, 14, 2]
|
|
||||||
*
|
|
||||||
stack is [14, 28]
|
|
||||||
SWAP
|
|
||||||
stack is [28, 14]
|
|
||||||
/
|
|
||||||
stack is [2]
|
|
||||||
+
|
|
||||||
ERROR: two numbers required
|
|
||||||
POP
|
|
||||||
stack is []
|
|
||||||
EXIT
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest stack
|
|
||||||
```
|
|
||||||
|
|||||||
89
collatz/README.md
Normal file
89
collatz/README.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# The Collatz conjecture
|
||||||
|
|
||||||
|
Original assessment brief: https://github.com/turner-townsend/backend-assessment/blob/master/README.md#the-collatz-conjecture
|
||||||
|
|
||||||
|
A Python program that calculates the number of steps in the Collatz sequence (3n + 1 problem) needed to reach 1.
|
||||||
|
|
||||||
|
## What is the Collatz conjecture?
|
||||||
|
|
||||||
|
The Collatz conjecture is an unsolved mathematical problem that defines a sequence:
|
||||||
|
|
||||||
|
- Start with any positive integer n
|
||||||
|
- If n is even: divide by 2
|
||||||
|
- If n is odd: multiply by 3 and add 1
|
||||||
|
- Repeat until n reaches 1
|
||||||
|
|
||||||
|
The conjecture states that this sequence will always eventually reach 1, regardless of the starting number.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- pytest (for running tests)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python collatz.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
Please enter a whole number: 1
|
||||||
|
Result: 0 steps needed to reach 1
|
||||||
|
Please enter a whole number: 3
|
||||||
|
Step #1: odd, multiply by 3 and add 1 -> 10
|
||||||
|
Step #2: even, divide by 2 -> 5
|
||||||
|
Step #3: odd, multiply by 3 and add 1 -> 16
|
||||||
|
Step #4: even, divide by 2 -> 8
|
||||||
|
Step #5: even, divide by 2 -> 4
|
||||||
|
Step #6: even, divide by 2 -> 2
|
||||||
|
Step #7: even, divide by 2 -> 1
|
||||||
|
Result: 8 steps needed to reach 3
|
||||||
|
Please enter a whole number: 0
|
||||||
|
Result: 0 steps needed to reach 0
|
||||||
|
Please enter a whole number: !£$
|
||||||
|
ERROR: integer required
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Step-by-step output**: Shows each transformation in the sequence
|
||||||
|
- **Interactive**: Continuous input until Ctrl+C
|
||||||
|
- **Input validation**: Handles invalid input gracefully
|
||||||
|
- **Edge cases**: Handles 1, 0, and negative numbers by returning 0 steps
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```
|
||||||
|
➜ collatz git:(main) ✗ pytest -v
|
||||||
|
============================================== test session starts ==============================================
|
||||||
|
platform linux -- Python 3.12.2, pytest-8.3.4, pluggy-1.5.0 -- /home/jamey/.venv/bin/python
|
||||||
|
cachedir: .pytest_cache
|
||||||
|
rootdir: /home/jamey/Projects/turner-townsend-backend-assessment/collatz
|
||||||
|
plugins: mock-3.14.0, asyncio-0.21.2
|
||||||
|
asyncio: mode=Mode.STRICT
|
||||||
|
collected 6 items
|
||||||
|
|
||||||
|
test_collatz.py::test_calculate_steps_for_1 PASSED [ 16%]
|
||||||
|
test_collatz.py::test_calculate_steps_for_2 PASSED [ 33%]
|
||||||
|
test_collatz.py::test_calculate_steps_for_3 PASSED [ 50%]
|
||||||
|
test_collatz.py::test_calculate_steps_for_6 PASSED [ 66%]
|
||||||
|
test_collatz.py::test_calculate_steps_for_large_number PASSED [ 83%]
|
||||||
|
test_collatz.py::test_calculate_steps_for_zero_and_negative PASSED [100%]
|
||||||
|
|
||||||
|
=============================================== 6 passed in 0.01s ===============================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- **Class-based structure**: Encapsulates the calculation logic for easy testing and reuse
|
||||||
|
- **Combined calculation and display**: The calculation method handles both computing steps and showing progress
|
||||||
|
- **Edge case handling**: Returns 0 steps for numbers ≤ 1
|
||||||
|
- **User-friendly**: Clear step-by-step output shows the mathematical process as it happens
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import readline
|
import readline
|
||||||
|
|
||||||
|
|
||||||
class CollatzCalculator:
|
class CollatzCalculator:
|
||||||
def calculate_steps(self, n: int) -> int:
|
def calculate_steps(self, n: int) -> int:
|
||||||
if n <= 1:
|
if n <= 1:
|
||||||
@ -15,16 +16,20 @@ class CollatzCalculator:
|
|||||||
steps += 1
|
steps += 1
|
||||||
return steps + 1
|
return steps + 1
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
calculator = CollatzCalculator()
|
calculator = CollatzCalculator()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
n = input("Please enter a whole number: ")
|
n = input("Please enter a whole number: ")
|
||||||
print(f"Result: {calculator.calculate_steps(int(n))} steps needed to reach {n}")
|
print(
|
||||||
|
f"Result: {calculator.calculate_steps(int(n))} steps needed to reach {n}"
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("ERROR: integer required")
|
print("ERROR: integer required")
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -1,26 +1,32 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from collatz import CollatzCalculator
|
from collatz import CollatzCalculator
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_steps_for_1():
|
def test_calculate_steps_for_1():
|
||||||
calc = CollatzCalculator()
|
calc = CollatzCalculator()
|
||||||
assert calc.calculate_steps(1) == 0
|
assert calc.calculate_steps(1) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_steps_for_2():
|
def test_calculate_steps_for_2():
|
||||||
calc = CollatzCalculator()
|
calc = CollatzCalculator()
|
||||||
assert calc.calculate_steps(2) == 2 # 2 -> 1 (even), returns 2 steps
|
assert calc.calculate_steps(2) == 2 # 2 -> 1 (even), returns 2 steps
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_steps_for_3():
|
def test_calculate_steps_for_3():
|
||||||
calc = CollatzCalculator()
|
calc = CollatzCalculator()
|
||||||
assert calc.calculate_steps(3) == 8 # 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
|
assert calc.calculate_steps(3) == 8 # 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_steps_for_6():
|
def test_calculate_steps_for_6():
|
||||||
calc = CollatzCalculator()
|
calc = CollatzCalculator()
|
||||||
assert calc.calculate_steps(6) == 9 # 6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
|
assert calc.calculate_steps(6) == 9 # 6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_steps_for_large_number():
|
def test_calculate_steps_for_large_number():
|
||||||
calc = CollatzCalculator()
|
calc = CollatzCalculator()
|
||||||
assert calc.calculate_steps(27) == 112
|
assert calc.calculate_steps(27) == 112
|
||||||
|
|
||||||
|
|
||||||
def test_calculate_steps_for_zero_and_negative():
|
def test_calculate_steps_for_zero_and_negative():
|
||||||
calc = CollatzCalculator()
|
calc = CollatzCalculator()
|
||||||
assert calc.calculate_steps(0) == 0
|
assert calc.calculate_steps(0) == 0
|
||||||
|
|||||||
86
roman/README.md
Normal file
86
roman/README.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Roman numerals converter
|
||||||
|
|
||||||
|
Original assessment brief: https://github.com/turner-townsend/backend-assessment/blob/master/README.md#roman-numerals
|
||||||
|
|
||||||
|
A simple Python program that converts Roman numerals to Arabic numbers using additive logic.
|
||||||
|
|
||||||
|
## Supported numerals
|
||||||
|
|
||||||
|
| Numeral | Value |
|
||||||
|
| ------- | ----- |
|
||||||
|
| I | 1 |
|
||||||
|
| V | 5 |
|
||||||
|
| X | 10 |
|
||||||
|
| C | 100 |
|
||||||
|
| M | 1000 |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python roman.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
Please enter some Roman numerals: I
|
||||||
|
I = 1
|
||||||
|
|
||||||
|
Please enter some Roman numerals: VI
|
||||||
|
VI = 5 + 1 = 6
|
||||||
|
|
||||||
|
Please enter some Roman numerals: MXVII
|
||||||
|
MXVII = 1000 + 10 + 5 + 1 + 1 = 1017
|
||||||
|
|
||||||
|
Please enter some Roman numerals: ABC
|
||||||
|
ERROR: Invalid input
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Additive conversion**: Treats all numerals as additive (IV = 1 + 5 = 6)
|
||||||
|
- **Case insensitive**: Accepts both uppercase and lowercase
|
||||||
|
- **Input filtering**: Ignores invalid characters, extracts valid numerals
|
||||||
|
- **Interactive**: Continuous input until Ctrl+C
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+ (uses pattern matching syntax)
|
||||||
|
- pytest (for running tests)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
➜ roman git:(main) ✗ pytest -v
|
||||||
|
================================================================ test session starts ================================================================
|
||||||
|
platform linux -- Python 3.12.2, pytest-8.3.4, pluggy-1.5.0 -- /home/jamey/.venv/bin/python
|
||||||
|
cachedir: .pytest_cache
|
||||||
|
rootdir: /home/jamey/Projects/turner-townsend-backend-assessment/roman
|
||||||
|
plugins: mock-3.14.0, asyncio-0.21.2
|
||||||
|
asyncio: mode=Mode.STRICT
|
||||||
|
collected 9 items
|
||||||
|
|
||||||
|
test_roman.py::test_valid_single_numerals PASSED [ 11%]
|
||||||
|
test_roman.py::test_valid_multiple_numerals PASSED [ 22%]
|
||||||
|
test_roman.py::test_lowercase_input PASSED [ 33%]
|
||||||
|
test_roman.py::test_mixed_case_input PASSED [ 44%]
|
||||||
|
test_roman.py::test_invalid_characters PASSED [ 55%]
|
||||||
|
test_roman.py::test_mixed_valid_and_invalid_characters PASSED [ 66%]
|
||||||
|
test_roman.py::test_empty_string PASSED [ 77%]
|
||||||
|
test_roman.py::test_only_spaces PASSED [ 88%]
|
||||||
|
test_roman.py::test_spaces_and_valid_numerals PASSED [100%]
|
||||||
|
|
||||||
|
================================================================= 9 passed in 0.02s =================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- **Simple approach**: Uses additive logic only, as the brief states complex Roman numeral rules are not required
|
||||||
|
- **User-friendly**: Filters out invalid characters rather than rejecting entire input
|
||||||
|
- **Modern Python**: Uses pattern matching (requires Python 3.10+)
|
||||||
@ -1,36 +1,61 @@
|
|||||||
import readline
|
import readline
|
||||||
|
|
||||||
def convert_to_number(numerals: str) -> str:
|
|
||||||
filtered = list(filter(lambda x: x in "IVXCM", list(numerals.upper())))
|
|
||||||
if len(filtered) == 0:
|
|
||||||
return "ERROR: Invalid input"
|
|
||||||
|
|
||||||
values: list[int] = []
|
class RomanNumeralConverter:
|
||||||
for i in range(len(filtered)):
|
def __init__(self):
|
||||||
match filtered[i]:
|
self.valid_numerals = "IVXCM"
|
||||||
case "I":
|
self.values = {"I": 1, "V": 5, "X": 10, "C": 100, "M": 1000}
|
||||||
values.append(1)
|
|
||||||
case "V":
|
def convert_to_number(self, numerals: str) -> str:
|
||||||
values.append(5)
|
filtered = self._filter_valid_numerals(numerals)
|
||||||
case "X":
|
if not filtered:
|
||||||
values.append(10)
|
return "ERROR: Invalid input"
|
||||||
case "C":
|
|
||||||
values.append(100)
|
values = self._convert_numerals_to_values(filtered)
|
||||||
case "M":
|
return self._format_output(filtered, values)
|
||||||
values.append(1000)
|
|
||||||
|
def _filter_valid_numerals(self, numerals: str) -> str:
|
||||||
|
"""Extract only valid Roman numerals from input."""
|
||||||
|
filtered_chars = [
|
||||||
|
char for char in numerals.upper() if char in self.valid_numerals
|
||||||
|
]
|
||||||
|
return "".join(filtered_chars)
|
||||||
|
|
||||||
|
def _convert_numerals_to_values(self, numerals: str) -> list[int]:
|
||||||
|
"""Convert Roman numeral characters to their integer values."""
|
||||||
|
values = []
|
||||||
|
for char in numerals:
|
||||||
|
match char:
|
||||||
|
case "I":
|
||||||
|
values.append(1)
|
||||||
|
case "V":
|
||||||
|
values.append(5)
|
||||||
|
case "X":
|
||||||
|
values.append(10)
|
||||||
|
case "C":
|
||||||
|
values.append(100)
|
||||||
|
case "M":
|
||||||
|
values.append(1000)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def _format_output(self, numerals: str, values: list[int]) -> str:
|
||||||
|
"""Format the conversion result for display."""
|
||||||
|
if len(values) == 1:
|
||||||
|
return f"{numerals} = {values[0]}"
|
||||||
|
else:
|
||||||
|
values_str = " + ".join(str(v) for v in values)
|
||||||
|
return f"{numerals} = {values_str} = {sum(values)}"
|
||||||
|
|
||||||
if len(values) > 1:
|
|
||||||
return f"{''.join(filtered)} = {' + '.join(str(v) for v in values)} = {sum(values)}"
|
|
||||||
else:
|
|
||||||
return f"{''.join(filtered)} = {values[0]}"
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
converter = RomanNumeralConverter()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
numerals = input("Please enter some Roman numerals: ")
|
numerals = input("Please enter some Roman numerals: ")
|
||||||
print(convert_to_number(numerals))
|
print(converter.convert_to_number(numerals))
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -1,38 +1,88 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from roman import convert_to_number
|
from roman import RomanNumeralConverter
|
||||||
|
|
||||||
|
ERROR_MESSAGE = "ERROR: Invalid input"
|
||||||
|
|
||||||
|
|
||||||
def test_valid_single_numerals():
|
def test_valid_single_numerals():
|
||||||
assert convert_to_number("I") == "I = 1"
|
converter = RomanNumeralConverter()
|
||||||
assert convert_to_number("V") == "V = 5"
|
assert converter.convert_to_number("I") == "I = 1"
|
||||||
assert convert_to_number("X") == "X = 10"
|
assert converter.convert_to_number("V") == "V = 5"
|
||||||
assert convert_to_number("C") == "C = 100"
|
assert converter.convert_to_number("X") == "X = 10"
|
||||||
assert convert_to_number("M") == "M = 1000"
|
assert converter.convert_to_number("C") == "C = 100"
|
||||||
|
assert converter.convert_to_number("M") == "M = 1000"
|
||||||
|
|
||||||
|
|
||||||
def test_valid_multiple_numerals():
|
def test_valid_multiple_numerals():
|
||||||
assert convert_to_number("IV") == "IV = 1 + 5 = 6"
|
converter = RomanNumeralConverter()
|
||||||
assert convert_to_number("XCM") == "XCM = 10 + 100 + 1000 = 1110"
|
assert converter.convert_to_number("IV") == "IV = 1 + 5 = 6"
|
||||||
assert convert_to_number("MCMXCIV") == "MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216"
|
assert converter.convert_to_number("XCM") == "XCM = 10 + 100 + 1000 = 1110"
|
||||||
|
assert (
|
||||||
|
converter.convert_to_number("MCMXCIV")
|
||||||
|
== "MCMXCIV = 1000 + 100 + 1000 + 10 + 100 + 1 + 5 = 2216"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_lowercase_input():
|
def test_lowercase_input():
|
||||||
assert convert_to_number("ivxcm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116"
|
converter = RomanNumeralConverter()
|
||||||
|
assert (
|
||||||
|
converter.convert_to_number("ivxcm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_case_input():
|
def test_mixed_case_input():
|
||||||
assert convert_to_number("iVxCm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116"
|
converter = RomanNumeralConverter()
|
||||||
|
assert (
|
||||||
|
converter.convert_to_number("iVxCm") == "IVXCM = 1 + 5 + 10 + 100 + 1000 = 1116"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_characters():
|
def test_invalid_characters():
|
||||||
assert convert_to_number("EFGH") == "Invalid input"
|
converter = RomanNumeralConverter()
|
||||||
assert convert_to_number("123") == "Invalid input"
|
assert converter.convert_to_number("EFGH") == ERROR_MESSAGE
|
||||||
assert convert_to_number("!@#") == "Invalid input"
|
assert converter.convert_to_number("123") == ERROR_MESSAGE
|
||||||
|
assert converter.convert_to_number("!@#") == ERROR_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_valid_and_invalid_characters():
|
def test_mixed_valid_and_invalid_characters():
|
||||||
assert convert_to_number("AIXB") == "IX = 1 + 10 = 11"
|
converter = RomanNumeralConverter()
|
||||||
assert convert_to_number("M1C2") == "MC = 1000 + 100 = 1100"
|
assert converter.convert_to_number("AIXB") == "IX = 1 + 10 = 11"
|
||||||
|
assert converter.convert_to_number("M1C2") == "MC = 1000 + 100 = 1100"
|
||||||
|
|
||||||
|
|
||||||
def test_empty_string():
|
def test_empty_string():
|
||||||
assert convert_to_number("") == "Invalid input"
|
converter = RomanNumeralConverter()
|
||||||
|
assert converter.convert_to_number("") == ERROR_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
def test_only_spaces():
|
def test_only_spaces():
|
||||||
assert convert_to_number(" ") == "Invalid input"
|
converter = RomanNumeralConverter()
|
||||||
|
assert converter.convert_to_number(" ") == ERROR_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
def test_spaces_and_valid_numerals():
|
def test_spaces_and_valid_numerals():
|
||||||
assert convert_to_number(" I V ") == "IV = 1 + 5 = 6"
|
converter = RomanNumeralConverter()
|
||||||
|
assert converter.convert_to_number(" I V ") == "IV = 1 + 5 = 6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_valid_numerals():
|
||||||
|
converter = RomanNumeralConverter()
|
||||||
|
assert converter._filter_valid_numerals("M1X2V") == "MXV"
|
||||||
|
assert converter._filter_valid_numerals("def") == ""
|
||||||
|
assert converter._filter_valid_numerals(" I V ") == "IV"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_numerals_to_values():
|
||||||
|
converter = RomanNumeralConverter()
|
||||||
|
assert converter._convert_numerals_to_values("IV") == [1, 5]
|
||||||
|
assert converter._convert_numerals_to_values("MXV") == [1000, 10, 5]
|
||||||
|
assert converter._convert_numerals_to_values("C") == [100]
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_output():
|
||||||
|
converter = RomanNumeralConverter()
|
||||||
|
assert converter._format_output("I", [1]) == "I = 1"
|
||||||
|
assert converter._format_output("IV", [1, 5]) == "IV = 1 + 5 = 6"
|
||||||
|
assert (
|
||||||
|
converter._format_output("MXV", [1000, 10, 5]) == "MXV = 1000 + 10 + 5 = 1015"
|
||||||
|
)
|
||||||
|
|||||||
282
stack/README.md
Normal file
282
stack/README.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
# Stack
|
||||||
|
|
||||||
|
Original assessment brief: https://github.com/turner-townsend/backend-assessment/blob/master/README.md#stack
|
||||||
|
|
||||||
|
A Python implementation of the Fifth stack-based programming language, demonstrating clean code practices, robust error handling, and modern Python features.
|
||||||
|
|
||||||
|
## What is Fifth?
|
||||||
|
|
||||||
|
Fifth is a stack-based language that operates on a stack of integers. All operations work with the top elements of the stack, following the last-in-first-out (LIFO) principle. This implementation provides an interactive REPL (read-eval-print loop) for executing Fifth commands.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Stack operations**: `PUSH`, `POP`, `SWAP`, `DUP`
|
||||||
|
- **Arithmetic operations**: `+`, `-`, `*`, `/` (floor division)
|
||||||
|
- **Comprehensive error handling**: Input validation, stack state checking, and clear error messages
|
||||||
|
- **Interactive REPL**: Command-line interface with readline support for command history (↑/↓)
|
||||||
|
- **Case-insensitive commands**: Commands work in both upper and lowercase
|
||||||
|
|
||||||
|
## Installation and usage
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Python 3.9+ (uses modern type hint syntax `list[int]`)
|
||||||
|
- No external dependencies
|
||||||
|
|
||||||
|
### Running the interpreter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python <filename>.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The interpreter will start with a help message and enter interactive mode, displaying the current stack state before each command.
|
||||||
|
|
||||||
|
## Commands reference
|
||||||
|
|
||||||
|
| Command | Description | Stack effect | Example |
|
||||||
|
| ---------- | --------------------------- | ----------------------------- | --------- |
|
||||||
|
| `PUSH <n>` | Push integer n onto stack | `[] → [n]` | `PUSH 42` |
|
||||||
|
| `POP` | Remove top element | `[..., n] → [...]` | `POP` |
|
||||||
|
| `SWAP` | Swap top two elements | `[..., a, b] → [..., b, a]` | `SWAP` |
|
||||||
|
| `DUP` | Duplicate top element | `[..., n] → [..., n, n]` | `DUP` |
|
||||||
|
| `+` | Add top two elements | `[..., a, b] → [..., (a+b)]` | `+` |
|
||||||
|
| `-` | Subtract (second - top) | `[..., a, b] → [..., (a-b)]` | `-` |
|
||||||
|
| `*` | Multiply top two elements | `[..., a, b] → [..., (a*b)]` | `*` |
|
||||||
|
| `/` | Floor divide (second / top) | `[..., a, b] → [..., (a//b)]` | `/` |
|
||||||
|
| `HELP` | Show available commands | No change | `HELP` |
|
||||||
|
| `EXIT` | Quit the program | - | `EXIT` |
|
||||||
|
|
||||||
|
### Example session
|
||||||
|
|
||||||
|
```
|
||||||
|
Available commands: HELP, PUSH, POP, SWAP, DUP
|
||||||
|
Available operations: +, -, *, /
|
||||||
|
stack is []
|
||||||
|
PUSH 3
|
||||||
|
stack is [3]
|
||||||
|
PUSH 11
|
||||||
|
stack is [3, 11]
|
||||||
|
+
|
||||||
|
stack is [14]
|
||||||
|
DUP
|
||||||
|
stack is [14, 14]
|
||||||
|
PUSH 2
|
||||||
|
stack is [14, 14, 2]
|
||||||
|
*
|
||||||
|
stack is [14, 28]
|
||||||
|
SWAP
|
||||||
|
stack is [28, 14]
|
||||||
|
/
|
||||||
|
stack is [2]
|
||||||
|
+
|
||||||
|
ERROR: two numbers required
|
||||||
|
POP
|
||||||
|
stack is []
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Run the test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
➜ stack git:(main) ✗ pytest -v
|
||||||
|
================================================================ test session starts ================================================================
|
||||||
|
platform linux -- Python 3.12.2, pytest-8.3.4, pluggy-1.5.0 -- /home/jamey/.venv/bin/python
|
||||||
|
cachedir: .pytest_cache
|
||||||
|
rootdir: /home/jamey/Projects/turner-townsend-backend-assessment/stack
|
||||||
|
plugins: mock-3.14.0, asyncio-0.21.2
|
||||||
|
asyncio: mode=Mode.STRICT
|
||||||
|
collected 22 items
|
||||||
|
|
||||||
|
test_stack.py::test_push_and_pop PASSED [ 4%]
|
||||||
|
test_stack.py::test_push_invalid_value PASSED [ 9%]
|
||||||
|
test_stack.py::test_pop_empty_stack PASSED [ 13%]
|
||||||
|
test_stack.py::test_swap PASSED [ 18%]
|
||||||
|
test_stack.py::test_swap_insufficient_elements PASSED [ 22%]
|
||||||
|
test_stack.py::test_dup PASSED [ 27%]
|
||||||
|
test_stack.py::test_dup_empty_stack PASSED [ 31%]
|
||||||
|
test_stack.py::test_addition PASSED [ 36%]
|
||||||
|
test_stack.py::test_subtraction PASSED [ 40%]
|
||||||
|
test_stack.py::test_multiplication PASSED [ 45%]
|
||||||
|
test_stack.py::test_division PASSED [ 50%]
|
||||||
|
test_stack.py::test_division_by_zero PASSED [ 54%]
|
||||||
|
test_stack.py::test_binary_op_insufficient_elements PASSED [ 59%]
|
||||||
|
test_stack.py::test_execute_unknown_command PASSED [ 63%]
|
||||||
|
test_stack.py::test_execute_push_command PASSED [ 68%]
|
||||||
|
test_stack.py::test_execute_pop_command PASSED [ 72%]
|
||||||
|
test_stack.py::test_execute_swap_command PASSED [ 77%]
|
||||||
|
test_stack.py::test_execute_dup_command PASSED [ 81%]
|
||||||
|
test_stack.py::test_help PASSED [ 86%]
|
||||||
|
test_stack.py::test_execute_empty_command PASSED [ 90%]
|
||||||
|
test_stack.py::test_execute_whitespace_command PASSED [ 95%]
|
||||||
|
test_stack.py::test_case_insensitivity PASSED [100%]
|
||||||
|
|
||||||
|
================================================================ 22 passed in 0.03s =================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design decisions and architecture
|
||||||
|
|
||||||
|
### Core design philosophy
|
||||||
|
|
||||||
|
The implementation prioritises **clarity and maintainability** over performance optimisation, following the principle of being "explicit over implicit" and "discoverable over performant" as requested in the brief.
|
||||||
|
|
||||||
|
### Key architectural choices
|
||||||
|
|
||||||
|
#### 1. Class-based structure (`FifthStack`)
|
||||||
|
|
||||||
|
- **Reasoning**: Encapsulates state (the stack) with behaviour (operations), making the code more organised and testable
|
||||||
|
- **Alternative considered**: Functional approach with global state, rejected for testing difficulties and state management complexity
|
||||||
|
|
||||||
|
#### 2. Command dictionary pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.commands: dict[str, Callable] = {
|
||||||
|
"help": self.help,
|
||||||
|
"push": self.push,
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Reasoning**: Makes adding new commands trivial and eliminates long if/elif chains
|
||||||
|
- **Benefit**: Extensible design - new commands require only adding to the dictionary
|
||||||
|
- **Trade-off**: Slight memory overhead for the dictionary, but gains significant maintainability
|
||||||
|
|
||||||
|
#### 3. Separate binary operations dictionary
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.binary_ops: dict[str, Callable[[int, int], int]] = {
|
||||||
|
"+": operator.add,
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Reasoning**: Leverages Python's `operator` module for built-in mathematical operations
|
||||||
|
- **Benefit**: Consistent behaviour with Python's arithmetic, less error-prone than custom implementations
|
||||||
|
- **Design**: Separate from commands because they have different signatures and error handling needs
|
||||||
|
|
||||||
|
#### 4. Method extraction for each operation
|
||||||
|
|
||||||
|
Each stack operation is implemented as a separate method rather than inline logic:
|
||||||
|
|
||||||
|
- **Testing**: Each operation can be unit tested independently
|
||||||
|
- **Debugging**: Stack traces clearly identify which operation failed
|
||||||
|
- **Extensibility**: New operations are self-contained and don't affect existing code
|
||||||
|
- **Readability**: Each method has a single, clear responsibility
|
||||||
|
|
||||||
|
#### 5. Defensive programming with `_require()` helper
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _require(self, number, message):
|
||||||
|
if len(self.stack) < number:
|
||||||
|
print(f"ERROR: {message}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Reasoning**: Centralizes stack size validation logic
|
||||||
|
- **Benefit**: Consistent error messages and reduces code duplication
|
||||||
|
- **DRY principle**: Single source of truth for stack requirement checking
|
||||||
|
|
||||||
|
#### 6. Error recovery strategy
|
||||||
|
|
||||||
|
- **Division by zero**: Restores original stack state (`self.stack.extend([a, b])`)
|
||||||
|
- **Invalid input**: Provides clear error messages without crashing
|
||||||
|
- **Insufficient stack**: Fails gracefully with descriptive messages
|
||||||
|
- **Reasoning**: Interpreter should be robust and not crash on user errors
|
||||||
|
|
||||||
|
#### 7. Modern Python features used
|
||||||
|
|
||||||
|
**Type hints with generic collections (Python 3.9+)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.stack: list[int] = []
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Reasoning**: Improves code readability and enables better IDE support
|
||||||
|
- **Choice**: Used `list[int]` instead of `typing.List[int]` for cleaner, modern syntax
|
||||||
|
|
||||||
|
**Input processing centralization**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _normalize_input(self, command: str) -> list[str]:
|
||||||
|
return command.lower().strip().split()
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Reasoning**: Single source of truth for command normalization and tokenization
|
||||||
|
- **Benefit**: Both REPL and direct execution paths use identical processing logic
|
||||||
|
- **Design choice**: Avoided walrus operator in favour of explicit method extraction for better testability and clarity
|
||||||
|
|
||||||
|
**f-string formatting**:
|
||||||
|
|
||||||
|
- **Reasoning**: More readable than `.format()` or `%` formatting
|
||||||
|
- **Performance**: Faster than alternative string formatting methods
|
||||||
|
|
||||||
|
#### 8. REPL design choices
|
||||||
|
|
||||||
|
**Stack display**: Shows stack state after each command
|
||||||
|
|
||||||
|
- **Reasoning**: Follows the specified output format from the brief
|
||||||
|
- **Implementation choice**: Used Python's native list representation for consistency with the expected format
|
||||||
|
- **Benefit**: Maintains familiar syntax for Python developers while meeting requirements
|
||||||
|
|
||||||
|
**Case insensitive commands**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
command.lower().strip().split()
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Reasoning**: More user-friendly, follows principle of least surprise
|
||||||
|
- **Benefit**: Reduces user errors from capitalisation mistakes
|
||||||
|
|
||||||
|
**Graceful exit**: Handles both `exit` command and Ctrl+C/EOF
|
||||||
|
|
||||||
|
- **Reasoning**: Proper CLI applications should handle interrupts gracefully
|
||||||
|
- **Implementation**: Try/catch for `EOFError` and `KeyboardInterrupt`
|
||||||
|
|
||||||
|
### Alternative approaches considered
|
||||||
|
|
||||||
|
1. **Parser-based approach**: Could have used a formal parser (e.g. PLY), but rejected as over-engineering for this simple language
|
||||||
|
|
||||||
|
2. **Functional approach**: Could have used pure functions with immutable data but rejected because:
|
||||||
|
|
||||||
|
- Stack operations naturally mutate state
|
||||||
|
- Would require returning new stack state from every function
|
||||||
|
- Less intuitive for this particular problem domain
|
||||||
|
|
||||||
|
3. **Command pattern with classes**: Could have made each command a separate class but rejected due to:
|
||||||
|
|
||||||
|
- Unnecessary complexity for simple operations
|
||||||
|
- Would require more boilerplate code
|
||||||
|
- Current approach is more discoverable
|
||||||
|
|
||||||
|
4. **eval() for arithmetic**: Could have used `eval()` for mathematical expressions but rejected due to:
|
||||||
|
- Security concerns
|
||||||
|
- Need for custom stack-based evaluation order
|
||||||
|
- Loss of control over error handling
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
The design facilitates testing through:
|
||||||
|
|
||||||
|
- **Isolated methods**: Each operation can be tested independently
|
||||||
|
- **Dependency injection**: Stack state can be set up for specific test scenarios
|
||||||
|
- **Clear return values**: Methods have predictable behaviour and side effects
|
||||||
|
- **Error conditions**: All error paths are reachable and testable
|
||||||
|
|
||||||
|
### Extensibility
|
||||||
|
|
||||||
|
Adding new features is straightforward:
|
||||||
|
|
||||||
|
- **New commands**: Add to `self.commands` dictionary and implement method
|
||||||
|
- **New operators**: Add to `self.binary_ops` dictionary
|
||||||
|
- **New data types**: Modify type hints and validation logic
|
||||||
|
- **New output formats**: Modify display logic in main loop
|
||||||
|
|
||||||
|
This architecture balances simplicity with software engineering practices, to keep the code readable and maintainable.
|
||||||
@ -20,6 +20,10 @@ class FifthStack:
|
|||||||
"/": operator.floordiv,
|
"/": operator.floordiv,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _normalize_input(self, command: str) -> list[str]:
|
||||||
|
"""Normalize and tokenize command input."""
|
||||||
|
return command.lower().strip().split()
|
||||||
|
|
||||||
def _require(self, number, message):
|
def _require(self, number, message):
|
||||||
if len(self.stack) < number:
|
if len(self.stack) < number:
|
||||||
print(f"ERROR: {message}")
|
print(f"ERROR: {message}")
|
||||||
@ -40,7 +44,10 @@ class FifthStack:
|
|||||||
self.stack.extend([a, b])
|
self.stack.extend([a, b])
|
||||||
|
|
||||||
def help(self):
|
def help(self):
|
||||||
print("Available commands:", ", ".join(cmd.upper() for cmd in self.commands.keys()))
|
print(
|
||||||
|
"Available commands:",
|
||||||
|
", ".join(cmd.upper() for cmd in self.commands.keys()),
|
||||||
|
)
|
||||||
print("Available operations:", ", ".join(self.binary_ops.keys()))
|
print("Available operations:", ", ".join(self.binary_ops.keys()))
|
||||||
|
|
||||||
def push(self, value):
|
def push(self, value):
|
||||||
@ -65,7 +72,7 @@ class FifthStack:
|
|||||||
self.stack.append(self.stack[-1])
|
self.stack.append(self.stack[-1])
|
||||||
|
|
||||||
def execute(self, command: str):
|
def execute(self, command: str):
|
||||||
tokens = command.lower().strip().split()
|
tokens = self._normalize_input(command)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -90,10 +97,12 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
print(f"stack is {fifth.stack}")
|
print(f"stack is {fifth.stack}")
|
||||||
try:
|
try:
|
||||||
if (command := input().strip().lower()) == "exit":
|
raw_input = input()
|
||||||
|
tokens = fifth._normalize_input(raw_input)
|
||||||
|
if tokens and tokens[0] == "exit":
|
||||||
break
|
break
|
||||||
if command:
|
if tokens:
|
||||||
fifth.execute(command)
|
fifth.execute(raw_input)
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@ -155,6 +155,7 @@ def test_help(capsys):
|
|||||||
assert "Available commands:" in captured.out
|
assert "Available commands:" in captured.out
|
||||||
assert "Available operations:" in captured.out
|
assert "Available operations:" in captured.out
|
||||||
|
|
||||||
|
|
||||||
def test_execute_empty_command():
|
def test_execute_empty_command():
|
||||||
stack = FifthStack()
|
stack = FifthStack()
|
||||||
stack.execute("") # Should do nothing
|
stack.execute("") # Should do nothing
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user