feat: add PlateInput component with Swedish plate validation and fake vehicle lookup

This commit is contained in:
Joakim Mörling 2026-05-01 17:38:28 +02:00
parent ce95a451ce
commit 078f07f2ac
7 changed files with 352 additions and 3 deletions

View file

@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '@/App.vue'
import router from '@/router'
describe('App', () => {
it('renders RouterView with HomePage content', async () => {
router.push('/')
await router.isReady()
const wrapper = mount(App, {
global: {
plugins: [router],
},
})
expect(wrapper.text()).toContain('BilHälsning')
})
})

View file

@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PlateInput from '@/components/PlateInput.vue'
function createWrapper(modelValue = '') {
return mount(PlateInput, {
props: { modelValue },
})
}
describe('PlateInput', () => {
it('renders an input element', () => {
const wrapper = createWrapper()
expect(wrapper.find('input').exists()).toBe(true)
})
it('auto-uppercases lowercase input', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('abc123')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC123'])
})
it('strips non-alphanumeric characters', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC 123')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC123'])
})
it('strips dashes and symbols', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC-12D')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC12D'])
})
it('shows error message for invalid format after input', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC12')
expect(wrapper.text()).toContain('giltigt registreringsnummer')
})
it('does not show error when input is empty', () => {
const wrapper = createWrapper()
expect(wrapper.text()).not.toContain('giltigt registreringsnummer')
})
it('emits lookup on valid ABC123 format', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC123')
expect(wrapper.emitted('lookup')).toBeTruthy()
expect(wrapper.emitted('lookup')?.[0]).toEqual(['ABC123'])
})
it('emits lookup on valid ABC12D format', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC12D')
expect(wrapper.emitted('lookup')).toBeTruthy()
expect(wrapper.emitted('lookup')?.[0]).toEqual(['ABC12D'])
})
it('does not emit lookup on invalid input', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC1')
expect(wrapper.emitted('lookup')).toBeFalsy()
})
it('updates modelValue via v-model', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC123')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC123'])
})
it('prevents re-emission for the same plate', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC123')
expect(wrapper.emitted('lookup')).toHaveLength(1)
await input.setValue('ABC12')
expect(wrapper.emitted('lookup')).toHaveLength(1)
await input.setValue('ABC123')
expect(wrapper.emitted('lookup')).toHaveLength(1)
})
it('emits again for a different valid plate', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
await input.setValue('ABC123')
expect(wrapper.emitted('lookup')).toHaveLength(1)
await input.setValue('')
await input.setValue('DEF456')
expect(wrapper.emitted('lookup')).toHaveLength(2)
expect(wrapper.emitted('lookup')?.[1]).toEqual(['DEF456'])
})
})

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import router from '@/router'
describe('Router', () => {
it('resolves / to HomePage', async () => {
await router.push('/')
await router.isReady()
expect(router.currentRoute.value.name).toBe('home')
})
it('does not crash on unknown route', async () => {
await router.push('/nonexistent')
await router.isReady()
expect(router.currentRoute.value.matched.length).toBe(0)
})
})

View file

@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
import { createPinia } from 'pinia'
describe('Pinia', () => {
it('creates a Pinia instance', () => {
const pinia = createPinia()
expect(pinia).toBeDefined()
expect(pinia.state).toBeDefined()
})
})

View file

@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const plate = defineModel<string>({ required: true })
const emit = defineEmits<{
(e: 'lookup', plate: string): void
}>()
const touched = ref(false)
const lastEmitted = ref('')
const SWEDISH_PLATE_REGEX = /^[A-Z]{3}(\d{3}|\d{2}[A-Z])$/
const isValid = computed(() => SWEDISH_PLATE_REGEX.test(plate.value ?? ''))
const showError = computed(
() => touched.value && !isValid.value && (plate.value?.length ?? 0) > 0,
)
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
const rawValue = target.value
const transformed = rawValue.toUpperCase().replace(/[^A-Z0-9]/g, '')
plate.value = transformed
touched.value = true
}
watch(isValid, (valid) => {
if (valid && plate.value && plate.value !== lastEmitted.value) {
lastEmitted.value = plate.value
emit('lookup', plate.value)
}
})
</script>
<template>
<div class="plate-input">
<label for="plate" class="plate-input__label">Registreringsnummer</label>
<input
id="plate"
type="text"
inputmode="text"
autocomplete="off"
spellcheck="false"
:value="plate"
class="plate-input__field"
:class="{ 'plate-input__field--error': showError }"
placeholder="ABC 123"
maxlength="7"
@input="handleInput"
/>
<p v-if="showError" class="plate-input__error">
Ange ett giltigt registreringsnummer
</p>
</div>
</template>
<style scoped>
.plate-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.plate-input__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.plate-input__field {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1.5rem;
font-family: monospace;
letter-spacing: 0.15em;
text-transform: uppercase;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.plate-input__field:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.plate-input__field--error {
border-color: #e53e3e;
}
.plate-input__field--error:focus {
border-color: #e53e3e;
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
}
.plate-input__error {
margin: 0;
font-size: 0.8125rem;
color: #e53e3e;
}
</style>

View file

@ -1,9 +1,107 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref } from 'vue'
import PlateInput from '@/components/PlateInput.vue'
interface VehicleInfo {
make: string
model: string
year: number
color: string
}
const FAKE_VEHICLES: Record<string, VehicleInfo> = {
ABC123: { make: 'Volvo', model: 'V70', year: 2009, color: 'Silver' },
ABC12D: { make: 'Volkswagen', model: 'Golf', year: 2020, color: 'Blå' },
XYZ789: { make: 'Saab', model: '9-3', year: 2005, color: 'Röd' },
}
const plate = ref('')
const vehicle = ref<VehicleInfo | null>(null)
const notFound = ref(false)
const lookingUp = ref(false)
function handleLookup(lookedUpPlate: string) {
lookingUp.value = true
notFound.value = false
vehicle.value = null
setTimeout(() => {
const found = FAKE_VEHICLES[lookedUpPlate]
if (found) {
vehicle.value = found
notFound.value = false
} else {
vehicle.value = null
notFound.value = true
}
lookingUp.value = false
}, 400)
}
</script>
<template>
<div>
<div class="home">
<h1>BilHälsning</h1>
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p>
<PlateInput v-model="plate" @lookup="handleLookup" />
<div v-if="lookingUp" class="home__status">Söker...</div>
<div v-else-if="vehicle" class="home__vehicle">
<p class="home__vehicle-text">
{{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) &mdash;
{{ vehicle.color }}
</p>
</div>
<div v-else-if="notFound" class="home__not-found">
<p>Inget fordon hittades för {{ plate }}</p>
</div>
</div>
</template>
<style scoped></style>
<style scoped>
.home {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.home__subtitle {
color: #718096;
margin: 0 0 1.5rem 0;
}
.home__status {
margin-top: 0.75rem;
color: #718096;
font-size: 0.875rem;
}
.home__vehicle {
margin-top: 0.75rem;
padding: 1rem;
background: #f0fff4;
border: 1px solid #c6f6d5;
border-radius: 0.5rem;
}
.home__vehicle-text {
margin: 0;
font-weight: 500;
}
.home__not-found {
margin-top: 0.75rem;
padding: 1rem;
background: #fffaf0;
border: 1px solid #feebc8;
border-radius: 0.5rem;
}
.home__not-found p {
margin: 0;
color: #c05621;
}
</style>