diff --git a/package-lock.json b/package-lock.json index 5878e7b..869d22d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "lonc-web", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { + "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", "bootstrap": "^5.3.3" }, @@ -1099,6 +1100,41 @@ "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", "license": "MIT" }, + "node_modules/@zxing/browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz", + "integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.21.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/alpinejs": { "version": "3.15.11", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz", @@ -1601,6 +1637,16 @@ "node": ">=14.0.0" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 7ad0a56..5360215 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "scripts": { @@ -12,6 +12,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", "bootstrap": "^5.3.3" }, diff --git a/src/api/stock.js b/src/api/stock.js index f29e018..fea63d7 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -68,6 +68,18 @@ function normalizeUpsertResponse(payload) { }; } +function normalizeIdentifierLookupResponse(payload) { + return { + status: payload?.status || null, + source: payload?.source || null, + cacheHit: Boolean(payload?.cache_hit), + identifierCode: payload?.identifier_code || null, + identifierType: payload?.identifier_type || null, + item: payload?.item || null, + payloadFetchedAt: payload?.payload_fetched_at || null, + }; +} + export async function previewItemUpsert(store, body) { const payload = await apiRequest(store, `${getPath('items')}/upsert`, { method: 'POST', @@ -88,6 +100,17 @@ export async function applyItemUpsert(store, body) { return normalizeUpsertResponse(payload); } +export async function lookupItemByIdentifier(store, identifierCode) { + const payload = await apiRequest(store, `${getPath('items')}/lookup`, { + method: 'POST', + body: { + identifier_code: String(identifierCode || '').trim(), + }, + }); + + return normalizeIdentifierLookupResponse(payload); +} + export async function updateStockItem(store, uuidB64, body) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, { method: 'POST', diff --git a/src/features/labels/identifier-lookup-mapper.js b/src/features/labels/identifier-lookup-mapper.js new file mode 100644 index 0000000..5a61248 --- /dev/null +++ b/src/features/labels/identifier-lookup-mapper.js @@ -0,0 +1,157 @@ +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function normalizedText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function nonEmptyText(value) { + const text = normalizedText(value); + return text ? text : null; +} + +function normalizedNumberText(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + + if (typeof value === 'string') { + const text = value.trim(); + if (!text) { + return null; + } + const parsed = Number(text); + if (Number.isFinite(parsed)) { + return String(parsed); + } + } + + return ''; +} + +function parseIsoDate(isoDate) { + if (!ISO_DATE_PATTERN.test(String(isoDate || ''))) { + return null; + } + + const [year, month, day] = String(isoDate).split('-').map(Number); + const parsed = new Date(year, month - 1, day); + + if ( + parsed.getFullYear() !== year + || parsed.getMonth() !== month - 1 + || parsed.getDate() !== day + ) { + return null; + } + + return parsed; +} + +function addDaysToIsoDate(isoDate, days) { + const parsed = parseIsoDate(isoDate); + if (!parsed) { + return ''; + } + + parsed.setDate(parsed.getDate() + days); + const year = parsed.getFullYear(); + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function diffIsoDays(fromIsoDate, toIsoDate) { + const fromDate = parseIsoDate(fromIsoDate); + const toDate = parseIsoDate(toIsoDate); + if (!fromDate || !toDate) { + return null; + } + + const millisecondsPerDay = 24 * 60 * 60 * 1000; + const diff = Math.round((toDate - fromDate) / millisecondsPerDay); + return diff >= 0 ? diff : null; +} + +function nonNegativeDays(value) { + if (value == null) { + return null; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + + return Math.round(parsed); +} + +export function deriveLookupExpirationDays(lookupItem) { + const explicitDays = nonNegativeDays(lookupItem?.expiration_days); + if (explicitDays !== null) { + return explicitDays; + } + + return diffIsoDays(lookupItem?.date, lookupItem?.expire_date); +} + +export function mapLookupItemToForm({ + form, + lookupItem, + locations = [], +}) { + const nextForm = { ...form }; + let nextLocationSearch = null; + let didUpdate = false; + + const setField = (targetField, value) => { + if (nextForm[targetField] === value) { + return; + } + + nextForm[targetField] = value; + didUpdate = true; + }; + + const textValue = (sourceField) => normalizedText(lookupItem?.[sourceField]); + const numberValue = (sourceField) => normalizedNumberText(lookupItem?.[sourceField]); + + setField('identifierCode', textValue('identifier_code')); + setField('name', textValue('name')); + setField('description', textValue('description')); + setField('stockType', textValue('stock_type')); + setField('level', textValue('level')); + setField('quantity', numberValue('quantity_initial')); + setField('uom', textValue('uom_symbol')); + setField('energy', numberValue('calories')); + setField('energyUnit', textValue('calories_unit')); + setField('externalSource', textValue('external_source')); + setField('externalId', textValue('external_id')); + + setField('search', nextForm.name); + + const expirationDays = deriveLookupExpirationDays(lookupItem); + if (expirationDays !== null) { + setField('expireDays', String(expirationDays)); + setField('expirationDate', addDaysToIsoDate(nextForm.productionDate, expirationDays)); + } else { + setField('expireDays', ''); + setField('expirationDate', ''); + } + + const canSetLocation = !String(nextForm.locationId || '').trim(); + const locationUuid = nonEmptyText(lookupItem?.location_initial_uuid_b64); + if (canSetLocation && locationUuid) { + const matchingLocation = locations.find((entry) => entry.uuid_b64 === locationUuid); + if (matchingLocation) { + nextForm.locationId = String(matchingLocation.id); + nextLocationSearch = matchingLocation.name; + didUpdate = true; + } + } + + return { + form: nextForm, + locationSearch: nextLocationSearch, + didUpdate, + }; +} diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index a7c09e6..1d592e4 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -1,8 +1,11 @@ import { applyItemUpsert, + lookupItemByIdentifier, previewItemUpsert, searchItemDefinitions, } from '../../api/stock.js'; +import { BrowserMultiFormatReader } from '@zxing/browser'; +import { mapLookupItemToForm } from './identifier-lookup-mapper.js'; import { fetchLocations } from '../../api/locations.js'; import { formatPrintErrorMessage, @@ -54,28 +57,75 @@ export function renderLabelCreatePage() {
-
- - - - +
+ Optional. Scan with camera or enter manually. +
+ +
@@ -470,6 +520,39 @@ export function renderLabelCreatePage() {
+ +
+
+
+
+
+

Scan barcode

+

Point your camera at the barcode to fill the identifier field.

+
+ +
+ +
+ +
+ +
+
Starting camera...
+
+ +
+ + +
+
+
`; } @@ -559,6 +642,7 @@ export function labelCreatePageData(store) { return { previewState: createAsyncState(), createState: createAsyncState(), + lookupState: createAsyncState(), stockTypeOptions: STOCK_TYPE_OPTIONS, stockLevelOptions: STOCK_LEVEL_OPTIONS, quantityUnitOptions: QUANTITY_UNIT_OPTIONS, @@ -577,10 +661,20 @@ export function labelCreatePageData(store) { upsertPreview: null, printLabelOnSave: true, printIssue: '', + scannerReader: null, + scannerControls: null, + scannerState: { + isOpen: false, + isLoading: false, + hasCamera: false, + error: '', + lastDetectedCode: '', + }, form: { ...loadLabelDraft(), }, async init() { + this.scannerState.hasCamera = this.canUseCameraScanner(); if (!store.isConnected) { return; } @@ -614,6 +708,243 @@ export function labelCreatePageData(store) { this.suggestions = await searchItemDefinitions(store, this.form.search.trim()); }, 250); }, + destroy() { + this.stopScanner(); + }, + canUseCameraScanner() { + return Boolean( + typeof navigator !== 'undefined' + && navigator.mediaDevices + && typeof navigator.mediaDevices.getUserMedia === 'function', + ); + }, + normalizeIdentifierCode(value) { + return String(value || '').replace(/\s+/g, '').trim(); + }, + hasLookupIdentifierCode() { + return Boolean(this.normalizeIdentifierCode(this.form.identifierCode)); + }, + lookupStatusMessage(status, identifierCode) { + const normalizedCode = this.normalizeIdentifierCode(identifierCode); + + if (!normalizedCode || status === 'missing_identifier') { + return 'Provide an identifier code before lookup.'; + } + + if (status === 'not_found') { + return `No lookup result found for code ${normalizedCode}.`; + } + + if (status === 'lookup_failed') { + return 'Lookup failed on the server. You can still fill the form manually.'; + } + + return 'Lookup response could not be applied to this form.'; + }, + normalizeScannerError(error) { + const message = String(error?.message || ''); + const normalized = message.toLowerCase(); + + if (error?.name === 'NotAllowedError' || normalized.includes('permission')) { + return 'Camera access was denied. Allow access to scan, or enter the code manually.'; + } + + if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) { + return 'No camera was found on this device. Enter the identifier code manually.'; + } + + if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) { + return 'Camera is busy in another app. Close it there and try scanning again.'; + } + + return 'Could not start barcode scanning. Enter the identifier code manually.'; + }, + async openScanner() { + this.scannerState.error = ''; + this.scannerState.lastDetectedCode = ''; + this.scannerState.isOpen = true; + await this.$nextTick(); + await this.startScanner(); + }, + async startScanner() { + this.scannerState.error = ''; + this.scannerState.lastDetectedCode = ''; + + if (!this.canUseCameraScanner()) { + this.scannerState.hasCamera = false; + this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the identifier code manually.'; + return; + } + + const videoElement = this.$refs.scannerVideo; + if (!videoElement) { + this.scannerState.error = 'Scanner video element is unavailable. Close and reopen scanner.'; + return; + } + + this.stopScanner(); + this.scannerState.isLoading = true; + + try { + if (!this.scannerReader) { + this.scannerReader = new BrowserMultiFormatReader(); + } + + this.scannerControls = await this.scannerReader.decodeFromConstraints( + { + audio: false, + video: { + facingMode: { ideal: 'environment' }, + }, + }, + videoElement, + (result, error) => { + if (result) { + this.onBarcodeDetected(result.getText?.() || ''); + return; + } + + if (!error || error?.name === 'NotFoundException') { + return; + } + + if (!this.scannerState.error) { + this.scannerState.error = this.normalizeScannerError(error); + } + }, + ); + } catch (error) { + this.scannerState.error = this.normalizeScannerError(error); + } finally { + this.scannerState.isLoading = false; + } + }, + stopScanner() { + try { + this.scannerControls?.stop?.(); + } catch { + // Ignore cleanup errors when scanner is already stopped. + } + this.scannerControls = null; + + try { + this.scannerReader?.reset?.(); + } catch { + // Ignore cleanup errors from stale reader state. + } + + const videoElement = this.$refs.scannerVideo; + const stream = videoElement?.srcObject; + if (stream && typeof stream.getTracks === 'function') { + stream.getTracks().forEach((track) => track.stop()); + } + if (videoElement) { + videoElement.srcObject = null; + } + }, + closeScanner() { + this.stopScanner(); + this.scannerState.isOpen = false; + this.scannerState.isLoading = false; + this.scannerState.error = ''; + }, + onBarcodeDetected(rawCode) { + const code = this.normalizeIdentifierCode(rawCode); + if (!code || !this.scannerState.isOpen) { + return; + } + + this.form.identifierCode = code; + this.scannerState.lastDetectedCode = code; + this.closeScanner(); + store.addAlert({ + type: 'success', + message: `Scanned identifier code: ${code}`, + }); + }, + async lookupIdentifierDetails() { + const identifierCode = this.normalizeIdentifierCode(this.form.identifierCode); + this.form.identifierCode = identifierCode; + this.lookupState.error = ''; + + if (!identifierCode) { + this.lookupState.error = 'Provide an identifier code before lookup.'; + return; + } + + await runAsyncState(this.lookupState, async () => { + const response = await lookupItemByIdentifier(store, identifierCode); + if (response.status !== 'ok') { + const message = this.lookupStatusMessage(response.status, identifierCode); + this.lookupState.error = message; + store.addAlert({ + type: response.status === 'not_found' ? 'info' : 'warning', + message, + }); + return; + } + + if (!response.item || typeof response.item !== 'object') { + const message = 'Lookup returned no item payload to apply.'; + this.lookupState.error = message; + store.addAlert({ + type: 'warning', + message, + }); + return; + } + + const mapped = mapLookupItemToForm({ + form: this.form, + lookupItem: response.item, + locations: this.locations, + }); + + if (!mapped.didUpdate) { + const message = 'Lookup finished, but no compatible fields were returned.'; + this.lookupState.error = message; + store.addAlert({ + type: 'info', + message, + }); + return; + } + + this.form = { + ...mapped.form, + itemId: '', + itemUuidB64: '', + }; + if (mapped.locationSearch !== null) { + this.locationSearch = mapped.locationSearch; + } + this.syncStockTypeState(this.form.stockType); + this.syncStockTypeSelect(); + this.syncStockLevelSelect(); + this.syncLocationValidity(); + this.upsertPreview = null; + this.previewState.error = ''; + this.submitError = ''; + if (this.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(this.previewUrl); + } + this.previewUrl = ''; + this.suggestions = []; + this.persistDraft(); + + const sourceSuffix = response.source ? ` (${response.source})` : ''; + const cacheSuffix = response.cacheHit ? ', cache hit' : ''; + store.addAlert({ + type: 'success', + message: `Lookup applied product details${sourceSuffix}${cacheSuffix}.`, + }); + }).catch((error) => { + store.addAlert({ + type: 'warning', + message: `Could not complete lookup: ${error.message || 'Unknown lookup error.'}`, + }); + }); + }, async loadLocations() { if (!store.isConnected) { return; @@ -1098,6 +1429,7 @@ export function labelCreatePageData(store) { }).catch(() => {}); }, reset(revokePreview = true) { + this.closeScanner(); this.form = createDefaultForm(); this.syncStockTypeState(this.form.stockType); this.suggestions = []; @@ -1105,6 +1437,7 @@ export function labelCreatePageData(store) { this.locationPickerOpen = false; this.successMessage = ''; this.submitError = ''; + this.lookupState.error = ''; this.fieldErrors = {}; this.upsertPreview = null; this.printIssue = ''; diff --git a/src/styles/app.css b/src/styles/app.css index 28d0d38..c4048e1 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -791,6 +791,36 @@ button.legend-card:focus-visible { cursor: pointer; } +.scanner-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1100; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(16, 24, 40, 0.62); + backdrop-filter: blur(2px); +} + +.scanner-modal { + width: min(40rem, 100%); + border-radius: 1rem; +} + +.scanner-video-shell { + border-radius: 0.85rem; + overflow: hidden; + border: 1px solid rgba(31, 75, 153, 0.2); + background: #0f172a; +} + +.scanner-video { + display: block; + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; +} + @media (max-width: 991.98px) { .navbar { backdrop-filter: blur(10px); @@ -804,4 +834,15 @@ button.legend-card:focus-visible { .empty-preview { border-radius: 1.25rem; } + + .scanner-modal-backdrop { + align-items: end; + } + + .scanner-modal { + width: 100%; + margin-bottom: 0.75rem; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } } diff --git a/tests/features/labels/identifier-lookup-mapper.test.js b/tests/features/labels/identifier-lookup-mapper.test.js new file mode 100644 index 0000000..229ea6a --- /dev/null +++ b/tests/features/labels/identifier-lookup-mapper.test.js @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveLookupExpirationDays, + mapLookupItemToForm, +} from '../../../src/features/labels/identifier-lookup-mapper.js'; + +function createForm(overrides = {}) { + return { + itemId: '', + itemUuidB64: '', + identifierCode: '', + externalSource: '', + externalId: '', + search: '', + name: '', + description: '', + quantity: '', + uom: 'g', + stockType: 'binary', + level: 'plenty', + energy: '', + energyUnit: 'kcal (100g/ml)', + productionDate: '2026-04-10', + expireDays: '', + expirationDate: '', + locationId: '', + ...overrides, + }; +} + +describe('identifier lookup form mapper', () => { + it('overwrites mapped fields with non-empty values and preserves production date', () => { + const form = createForm({ + name: 'Old name', + productionDate: '2026-04-10', + }); + const lookupItem = { + identifier_code: ' 3830012345678 ', + name: 'Yogurt', + description: 'Plain yogurt', + stock_type: 'measured', + level: 'low', + quantity_initial: 0, + uom_symbol: 'ml', + calories: 61, + calories_unit: 'kcal', + external_source: 'openfoodfacts', + external_id: 'off-123', + expiration_days: 5, + location_initial_uuid_b64: 'loc-freezer', + }; + const locations = [ + { id: 44, uuid_b64: 'loc-freezer', name: 'Freezer' }, + ]; + + const result = mapLookupItemToForm({ + form, + lookupItem, + locations, + }); + + expect(result.didUpdate).toBe(true); + expect(result.form.identifierCode).toBe('3830012345678'); + expect(result.form.name).toBe('Yogurt'); + expect(result.form.search).toBe('Yogurt'); + expect(result.form.description).toBe('Plain yogurt'); + expect(result.form.stockType).toBe('measured'); + expect(result.form.level).toBe('low'); + expect(result.form.quantity).toBe('0'); + expect(result.form.uom).toBe('ml'); + expect(result.form.energy).toBe('61'); + expect(result.form.energyUnit).toBe('kcal'); + expect(result.form.externalSource).toBe('openfoodfacts'); + expect(result.form.externalId).toBe('off-123'); + expect(result.form.productionDate).toBe('2026-04-10'); + expect(result.form.expireDays).toBe('5'); + expect(result.form.expirationDate).toBe('2026-04-15'); + expect(result.form.locationId).toBe('44'); + expect(result.locationSearch).toBe('Freezer'); + }); + + it('clears mapped fields when lookup values are empty or null', () => { + const form = createForm({ + name: 'Keep me', + description: 'Still here', + quantity: '2', + uom: 'pc', + energy: '120', + energyUnit: 'kcal', + stockType: 'descriptive', + level: 'some', + search: 'Keep me', + identifierCode: '12345678', + externalSource: 'cache', + externalId: 'xyz', + expireDays: '3', + expirationDate: '2026-04-13', + }); + const lookupItem = { + name: ' ', + description: null, + quantity_initial: null, + uom_symbol: '', + calories: null, + calories_unit: '', + stock_type: '', + level: '', + identifier_code: '', + external_source: null, + external_id: ' ', + date: 'bad-date', + expire_date: '2026-05-20', + }; + + const result = mapLookupItemToForm({ + form, + lookupItem, + locations: [], + }); + + expect(result.didUpdate).toBe(true); + expect(result.form.identifierCode).toBe(''); + expect(result.form.name).toBe(''); + expect(result.form.search).toBe(''); + expect(result.form.description).toBe(''); + expect(result.form.stockType).toBe(''); + expect(result.form.level).toBe(''); + expect(result.form.quantity).toBe(''); + expect(result.form.uom).toBe(''); + expect(result.form.energy).toBe(''); + expect(result.form.energyUnit).toBe(''); + expect(result.form.externalSource).toBe(''); + expect(result.form.externalId).toBe(''); + expect(result.form.expireDays).toBe(''); + expect(result.form.expirationDate).toBe(''); + expect(result.form.productionDate).toBe('2026-04-10'); + }); + + it('derives expiration days from date/expire_date when expiration_days is missing', () => { + const days = deriveLookupExpirationDays({ + date: '2026-01-02', + expire_date: '2026-01-12', + }); + + expect(days).toBe(10); + }); + + it('updates location only when current form location is empty', () => { + const lookupItem = { + location_initial_uuid_b64: 'loc-fridge', + }; + const locations = [ + { id: 5, uuid_b64: 'loc-fridge', name: 'Fridge' }, + ]; + + const withExistingLocation = mapLookupItemToForm({ + form: createForm({ locationId: '8' }), + lookupItem, + locations, + }); + const withoutLocation = mapLookupItemToForm({ + form: createForm({ locationId: '' }), + lookupItem, + locations, + }); + + expect(withExistingLocation.form.locationId).toBe('8'); + expect(withExistingLocation.locationSearch).toBeNull(); + expect(withoutLocation.form.locationId).toBe('5'); + expect(withoutLocation.locationSearch).toBe('Fridge'); + }); +});