Skip to content
Merged
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
103 changes: 103 additions & 0 deletions app/api/routesF/roman-numeral-validator/route.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
79 changes: 79 additions & 0 deletions app/api/routesF/roman-numeral-validator/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';

type RomanValidationResult = {
valid: boolean;
value?: number;
reason?: string;
};

const ROMAN_VALUES: Record<string, number> = {
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 });
}
}
Loading