feat: add PlateInput component with Swedish plate validation and fake vehicle lookup
This commit is contained in:
parent
ce95a451ce
commit
078f07f2ac
7 changed files with 352 additions and 3 deletions
17
frontend/src/__tests__/App.spec.ts
Normal file
17
frontend/src/__tests__/App.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
102
frontend/src/__tests__/PlateInput.spec.ts
Normal file
102
frontend/src/__tests__/PlateInput.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
16
frontend/src/__tests__/Router.spec.ts
Normal file
16
frontend/src/__tests__/Router.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
10
frontend/src/__tests__/Store.spec.ts
Normal file
10
frontend/src/__tests__/Store.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
106
frontend/src/components/PlateInput.vue
Normal file
106
frontend/src/components/PlateInput.vue
Normal 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>
|
||||
|
|
@ -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 }}) —
|
||||
{{ 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue