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>
|
<template>
|
||||||
<div>
|
<div class="home">
|
||||||
<h1>BilHälsning</h1>
|
<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>
|
</div>
|
||||||
</template>
|
</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