diff --git a/app/api/routesF/roman-numeral-validator/route.test.ts b/app/api/routesF/roman-numeral-validator/route.test.ts new file mode 100644 index 00000000..b409a54e --- /dev/null +++ b/app/api/routesF/roman-numeral-validator/route.test.ts @@ -0,0 +1,103 @@ +import { POST } from './route'; + +async function validate(roman: unknown) { + const req = new Request('http://localhost/api/routesF/roman-numeral-validator', { + method: 'POST', + body: JSON.stringify({ roman }), + }); + + const res = await POST(req); + return { + status: res.status, + data: await res.json(), + }; +} + +describe('Roman numeral validator API', () => { + it('accepts legal strict Roman numerals', async () => { + await expect(validate('I')).resolves.toEqual({ + status: 200, + data: { valid: true, value: 1 }, + }); + + await expect(validate('IV')).resolves.toEqual({ + status: 200, + data: { valid: true, value: 4 }, + }); + + await expect(validate('XLII')).resolves.toEqual({ + status: 200, + data: { valid: true, value: 42 }, + }); + + await expect(validate('MCMXCIV')).resolves.toEqual({ + status: 200, + data: { valid: true, value: 1994 }, + }); + + await expect(validate('MMMCMXCIX')).resolves.toEqual({ + status: 200, + data: { valid: true, value: 3999 }, + }); + }); + + it('rejects illegal additive and repeated forms', async () => { + for (const roman of ['IIII', 'VV', 'XXXX', 'LL', 'CCCC', 'DD']) { + const { status, data } = await validate(roman); + + expect(status).toBe(200); + expect(data.valid).toBe(false); + expect(data.reason).toBe('Roman numeral is not in strict subtractive notation'); + } + }); + + it('rejects illegal subtractive forms', async () => { + for (const roman of ['IC', 'IL', 'XD', 'XM', 'VX', 'LC']) { + const { status, data } = await validate(roman); + + expect(status).toBe(200); + expect(data.valid).toBe(false); + expect(data.reason).toBe('Roman numeral is not in strict subtractive notation'); + } + }); + + it('rejects malformed input', async () => { + await expect(validate('')).resolves.toMatchObject({ + status: 200, + data: { valid: false, reason: 'Roman numeral cannot be empty' }, + }); + + await expect(validate(' ix')).resolves.toMatchObject({ + status: 200, + data: { valid: false, reason: 'Roman numeral cannot include whitespace' }, + }); + + await expect(validate('ix')).resolves.toMatchObject({ + status: 200, + data: { valid: false, reason: 'Roman numeral must use uppercase letters' }, + }); + + await expect(validate('ABC')).resolves.toMatchObject({ + status: 200, + data: { valid: false, reason: 'Roman numeral contains invalid characters' }, + }); + + await expect(validate(42)).resolves.toMatchObject({ + status: 200, + data: { valid: false, reason: 'Roman numeral must be a string' }, + }); + }); + + it('returns 400 for invalid JSON', async () => { + const req = new Request('http://localhost/api/routesF/roman-numeral-validator', { + method: 'POST', + body: '{', + }); + + const res = await POST(req); + const data = await res.json(); + + expect(res.status).toBe(400); + expect(data).toEqual({ error: 'Invalid JSON body' }); + }); +}); diff --git a/app/api/routesF/roman-numeral-validator/route.ts b/app/api/routesF/roman-numeral-validator/route.ts new file mode 100644 index 00000000..8e38e5c9 --- /dev/null +++ b/app/api/routesF/roman-numeral-validator/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; + +type RomanValidationResult = { + valid: boolean; + value?: number; + reason?: string; +}; + +const ROMAN_VALUES: Record = { + I: 1, + V: 5, + X: 10, + L: 50, + C: 100, + D: 500, + M: 1000, +}; + +const ROMAN_CHARACTERS = /^[IVXLCDM]+$/; +const STRICT_ROMAN_NUMERAL = + /^(?=.)M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/; + +function romanToNumber(roman: string): number { + let total = 0; + + for (let index = 0; index < roman.length; index++) { + const current = ROMAN_VALUES[roman[index]]; + const next = ROMAN_VALUES[roman[index + 1]] ?? 0; + + total += current < next ? -current : current; + } + + return total; +} + +function validateRoman(roman: unknown): RomanValidationResult { + if (typeof roman !== 'string') { + return { valid: false, reason: 'Roman numeral must be a string' }; + } + + if (roman.length === 0) { + return { valid: false, reason: 'Roman numeral cannot be empty' }; + } + + if (roman.trim() !== roman) { + return { valid: false, reason: 'Roman numeral cannot include whitespace' }; + } + + if (roman !== roman.toUpperCase()) { + return { valid: false, reason: 'Roman numeral must use uppercase letters' }; + } + + if (!ROMAN_CHARACTERS.test(roman)) { + return { valid: false, reason: 'Roman numeral contains invalid characters' }; + } + + if (!STRICT_ROMAN_NUMERAL.test(roman)) { + return { valid: false, reason: 'Roman numeral is not in strict subtractive notation' }; + } + + const value = romanToNumber(roman); + + if (value < 1 || value > 3999) { + return { valid: false, reason: 'Roman numeral must be in the range 1-3999' }; + } + + return { valid: true, value }; +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = validateRoman(body?.roman); + + return NextResponse.json(result); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } +}