diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index 12d62d4..0913578 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -6,6 +6,7 @@ + @@ -29,4 +30,4 @@ - \ No newline at end of file + diff --git a/src/main/webapp/meal-page-script.js b/src/main/webapp/meal-page-script.js index 489dd86..c593338 100644 --- a/src/main/webapp/meal-page-script.js +++ b/src/main/webapp/meal-page-script.js @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Alias the service. +// Use the `RealMealService` to actually query the backend. +const service = FakeMealService; + function fetchMealInfo() { // use mapping /meal.html?id= // fetches form server by action meal/ @@ -22,14 +26,7 @@ function fetchMealInfo() { window.location.replace("error.html"); } - fetch(`/meal/${id}`) - .then((response) => { - if (!response.ok) { - throw new Error("Network response was not ok"); - window.location.replace("error.html"); - } - return response.json(); - }) + service.getOne(id) .then((meal) => { const { title, description, ingredients, type } = meal; const titleElement = document.getElementById("title"); @@ -54,8 +51,7 @@ function redirectToSimilar() { const queryString = window.location.search; const urlParams = new URLSearchParams(queryString); const pageId = urlParams.get("id") ?? 0; - fetch(`/meal/similar?id=${pageId.toString()}`) - .then((response) => response.json()) + service.getSimilar(pageId.toString()) .then((id) => { const url = `/meal.html?id=${id.toString()}`; window.location.replace(url); diff --git a/src/main/webapp/meal.html b/src/main/webapp/meal.html index 2a9197c..dbfdd66 100644 --- a/src/main/webapp/meal.html +++ b/src/main/webapp/meal.html @@ -9,6 +9,7 @@ + @@ -39,4 +40,4 @@

Explore on a map:

- \ No newline at end of file + diff --git a/src/main/webapp/script.js b/src/main/webapp/script.js deleted file mode 100644 index 4d803a5..0000000 --- a/src/main/webapp/script.js +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2020 Google LLC - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// https://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - - -function createElementByTag(text, tag) { - const element = document.createElement(tag); - element.innerText = text; - return element; -} - -function capitalizeItems(string) { - return string - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -// solution from https://stackoverflow.com/questions/20174280/nodejs-convert-string-into-utf-8 -function encodingCheck(string) { - return JSON.parse(JSON.stringify(string)); -} - -function getQueryParam(param) { - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - return urlParams.get(param) ?? ""; -} diff --git a/src/main/webapp/search-meal-script.js b/src/main/webapp/search-meal-script.js index 673b53a..e5e1a6a 100644 --- a/src/main/webapp/search-meal-script.js +++ b/src/main/webapp/search-meal-script.js @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +// See a comment in meal-page-script.js +const service = FakeMealService; + function searchMeal() { const searchLine = getQueryParam("query"); - fetch(`/meal?query=${searchLine}`) - .then((response) => response.json()) + service.query(searchLine) .then((dishes) => { const amount = document.getElementById('amount-block'); const isSingular = dishes.length == 1; diff --git a/src/main/webapp/search-results.html b/src/main/webapp/search-results.html index a211cd5..3dc3c4c 100644 --- a/src/main/webapp/search-results.html +++ b/src/main/webapp/search-results.html @@ -7,6 +7,7 @@ + @@ -30,4 +31,4 @@ - \ No newline at end of file + diff --git a/src/main/webapp/service.js b/src/main/webapp/service.js new file mode 100644 index 0000000..15c8108 --- /dev/null +++ b/src/main/webapp/service.js @@ -0,0 +1,75 @@ +// This module exposes objects responsible for communicating with the backend. +// One "proper" version, talking to the real Java service, and one fake +// variant, returning hardcoded values, useful for local testing. +// +// All communication with the service should be done through one of the objects +// below. +// All exported objects should implement the same interface (the same set of +// public methods, each with the exact same signature). If we used TypeScript, +// we'd have defined an +// [interface](https://www.typescriptlang.org/docs/handbook/interfaces.html). +// +// NOTE(zajonc): the reason I've introduced this layer is because I wanted to +// detach the frontend part from the backend. +// Ideally, we'd like to be able to run a simple HTTP server serving the +// frontend, without the need to spin up Tomcat / Google App Engine. +// By mocking the service calls, we get rid of a strong dependency on the +// server. +// (TLDR: it's easier to test a Vue app this way). +// It's often a good idea to have a clear split between the frontend and the +// backend, though. In many cases they may even belong to separate +// repositories. You may even have multiple frontend apps for the same backend. + +// Builds HTTP requests and forwards them to the right endpoint. +// NOTE: I wasn't really able to test it - I might have messed something up here. +const RealMealService = { + getOne: (mealId) => + fetch(`/meal/${mealId}`).then((response) => { + if (!response.ok) { + throw new Error("Network response was not ok"); + window.location.replace("error.html"); + } + return response.json(); + }), + getSimilar: (mealId) => + fetch(`/meal/similar?id=${mealId}`).then((response) => response.json()), + query: (queryContent) => + fetch(`/meal?query=${queryContent}`).then((response) => response.json()), +}; + +// Fake variant, returning hardcoded values. +// No real network requests are made to the service. +// All methods return `Promise`s, for compatibility with the real service. +const FakeMealService = { + getOne: (mealId) => { + if (mealId == 1) { + return Promise.resolve(fakeMealA); + } else if (mealId == 2) { + return Promise.resolve(fakeMealB); + } else { + return Promise.resolve(null); + } + }, + // If current meal is A, suggest B. Suggest A otherwise. + getSimilar: (mealId) => + Promise.resolve(mealId == fakeMealA.id ? fakeMealB.id : fakeMealA.id), + // Return all known meals, regardless of the query. + query: (queryContent) => Promise.resolve([fakeMealA, fakeMealB]), +}; + +// NOTE(zajonc): I'm not too creative :/ +const fakeMealA = { + id: 1, + title: "MealA", + description: "MealADescription", + ingredients: ["Ingredient1, Ingredient2"], + type: "MealAType", +}; + +const fakeMealB = { + id: 2, + title: "MealB", + description: "MealBDescription", + ingredients: ["Ingredient3, Ingredient4"], + type: "MealBType", +}; diff --git a/src/main/webapp/vue-app/README.md b/src/main/webapp/vue-app/README.md new file mode 100644 index 0000000..e921d28 --- /dev/null +++ b/src/main/webapp/vue-app/README.md @@ -0,0 +1,54 @@ +# vue-app + +A simple rewrite of the basic functionality of the client, using Vue.js for all frontend code. +Some features are most likely missing. The main goal is to set up and use a usable environment +for working with modern frontend apps. + +## SPA + +The result is a [Single Page App](https://en.wikipedia.org/wiki/Single-page_application). + +The main difference between a SPA and a regular, oldschool app, is that there is only one html file +serving all subpages. It's content is dynamically rendered based on some magic JS code, usually provided +by a framework (here: Vue.js). + +## Development + +*Important* +To run any of the tools described below, you need to install [NodeJS](https://nodejs.org/en/) on your machine. + +From a developer's perspective, one of the key differences between the old approach (including js files in a +HTML file, which populate the global namespace) and the modern approach used in this directory, is the introduction +of a build step - the files we write are not the same files that are seen by the browser. + +A browser is able to render HTML files, which may define script dependencies as ` + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/assets/logo.png b/src/main/webapp/vue-app/meal-assistant-client/src/assets/logo.png new file mode 100644 index 0000000..f3d2503 Binary files /dev/null and b/src/main/webapp/vue-app/meal-assistant-client/src/assets/logo.png differ diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/components/Header.vue b/src/main/webapp/vue-app/meal-assistant-client/src/components/Header.vue new file mode 100644 index 0000000..6e67198 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/components/Header.vue @@ -0,0 +1,40 @@ + + + + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/components/Map.vue b/src/main/webapp/vue-app/meal-assistant-client/src/components/Map.vue new file mode 100644 index 0000000..a35ce0d --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/components/Map.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/components/PhotoCopyright.vue b/src/main/webapp/vue-app/meal-assistant-client/src/components/PhotoCopyright.vue new file mode 100644 index 0000000..f3c176b --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/components/PhotoCopyright.vue @@ -0,0 +1,10 @@ + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/components/README.md b/src/main/webapp/vue-app/meal-assistant-client/src/components/README.md new file mode 100644 index 0000000..648b4f2 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/components/README.md @@ -0,0 +1,10 @@ +# Components + +Reusable Vue components. +As a rule of thumb, they should be kept small. If you find yourself writing some +complex functions as coponent methods, consider extracting the logic to a +standalone, pure-JS function and importing it into the component. + +Components can be tested by rendering them inside a browser-like environment. +[Vue testing library](https://testing-library.com/docs/vue-testing-library/intro/) +provides a set of utilities that make testing Vue components easy. diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/components/SearchBar.vue b/src/main/webapp/vue-app/meal-assistant-client/src/components/SearchBar.vue new file mode 100644 index 0000000..a00ba04 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/components/SearchBar.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/components/ShowSimilarMealButton.vue b/src/main/webapp/vue-app/meal-assistant-client/src/components/ShowSimilarMealButton.vue new file mode 100644 index 0000000..334ceaa --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/components/ShowSimilarMealButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/logic/README.md b/src/main/webapp/vue-app/meal-assistant-client/src/logic/README.md new file mode 100644 index 0000000..2c7d025 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/logic/README.md @@ -0,0 +1,5 @@ +# Logic + +It is often useful to separate pure JS logic (functions and classes) from framework-specific components. + +The reason is the same as always - the simpler the module, the easier it is to maintain (== test). diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/logic/helpers.js b/src/main/webapp/vue-app/meal-assistant-client/src/logic/helpers.js new file mode 100644 index 0000000..581e405 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/logic/helpers.js @@ -0,0 +1,23 @@ +export function createElementByTag(text, tag) { + const element = document.createElement(tag); + element.innerText = text; + return element; +} + +export function capitalizeItems(string) { + return string + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +// solution from https://stackoverflow.com/questions/20174280/nodejs-convert-string-into-utf-8 +export function encodingCheck(string) { + return JSON.parse(JSON.stringify(string)); +} + +export function getQueryParam(param) { + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + return urlParams.get(param) ?? ""; +} diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/logic/map_utils.js b/src/main/webapp/vue-app/meal-assistant-client/src/logic/map_utils.js new file mode 100644 index 0000000..bb9c8ca --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/logic/map_utils.js @@ -0,0 +1,113 @@ +// Creates a map and adds it to the page. +// +// NOTE(zajonc): I've reused the existing code for simplicity. +// However, since we're using a modern framework, we could've simply used an existing library +// that solves all the manual stuff and exposes an easy to use API. +// [vue2-google-maps](https://www.npmjs.com/package/vue2-google-maps) seems like an interesting option. +// +// The JS ecosystem is massive. Whenever you try to use some popular tool (like Google Maps) from +// within a popular framework (like Vue), there's a huge chance you can reuse some library to make your +// life easier. +// This is possible because of the added build step - we can simply download dependencies from npm and bundle +// them into our final script. +// +// NOTE(zajonc): I've added an `element` argument to make the function a bit easier to use. In general, it's +// a good idea to avoid looking things up in the global state (here: `document.getElementById`) in helper functions. +// You can always pass things as arguments. +export function createMap(type, element) { + const userLocation = new google.maps.LatLng(55.746514, 37.627022); + const mapOptions = { + zoom: 14, + center: userLocation, + }; + const map = new google.maps.Map(element, mapOptions); + const locationMarker = new google.maps.Marker({ + position: userLocation, + map, + title: "You are here (probably)", + label: "You", + animation: google.maps.Animation.DROP, + }); + + // Geolocation warning will be retujrned from the promise. + // If is not a critical error, therefore we do not reject the promise. + let geoWarning = null; + + return getCurrentPositionPromise() + .then((position) => { + const location = new google.maps.LatLng( + position.coords.latitude, + position.coords.longitude + ); + map.setCenter(location); + locationMarker.setPosition(location); + }) + .catch((err) => { + if (err === "NO_GEOLOCATION") { + geoWarning = "Your browser doesn't support geolocation."; + } else if (err === "GET_POSITION_FAILED") { + geoWarning = + "The Geolocation service failed. Share your location, please."; + } + }) + .then(() => { + const request = { + location: map.getCenter(), + radius: "1500", + type: ["restaurant", "cafe", "meal_takeaway", "bar", "bakery"], + query: type, + }; + const service = new google.maps.places.PlacesService(map); + return getSearchPromise(service, request, map).then((results) => { + addRestaurants(results, map); + }); + }) + .catch((err) => { + console.log(`Caught error: ${err}`); + }) + .finally(() => geoWarning); +} + +function addRestaurants(results, map) { + for (const result of results) { + createMarker(result, map); + } +} + +function getSearchPromise(service, request) { + return new Promise((resolve, reject) => { + const onSuccess = (results, status) => { + if (status !== "OK") { + reject("FAILED"); + } else { + resolve(results); + } + }; + const onError = () => reject("FAILED"); + service.textSearch(request, onSuccess, onError); + }); +} + +function getCurrentPositionPromise() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject("NO_GEOLOCATION"); + } else { + const onSuccess = (position) => resolve(position); + const onError = () => reject("GET_POSITION_FAILED"); + navigator.geolocation.getCurrentPosition(onSuccess, onError); + } + }); +} + +function createMarker(place, map) { + const marker = new google.maps.Marker({ + map, + position: place.geometry.location, + title: place.name, + }); + const infoWindow = new google.maps.InfoWindow({ content: place.name }); + marker.addListener("click", () => { + infoWindow.open(map, marker); + }); +} diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/logic/service.js b/src/main/webapp/vue-app/meal-assistant-client/src/logic/service.js new file mode 100644 index 0000000..7cc6789 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/logic/service.js @@ -0,0 +1,74 @@ +// This module exposes objects responsible for communicating with the backend. +// One "proper" version, talking to the real Java service, and one fake +// variant, returning hardcoded values, useful for local testing. +// +// All communication with the service should be done through one of the objects +// below. +// All exported objects should implement the same interface (the same set of +// public methods, each with the exact same signature). If we used TypeScript, +// we'd have defined an +// [interface](https://www.typescriptlang.org/docs/handbook/interfaces.html). +// +// NOTE(zajonc): the reason I've introduced this layer is because I wanted to +// detach the frontend part from the backend. +// Ideally, we'd like to be able to run a simple HTTP server serving the +// frontend, without the need to spin up Tomcat / Google App Engine. +// By mocking the service calls, we get rid of a strong dependency on the +// server. +// (TLDR: it's easier to test a Vue app this way). +// It's often a good idea to have a clear split between the frontend and the +// backend, though. In many cases they may even belong to separate +// repositories. You may even have multiple frontend apps for the same backend. + +// Builds HTTP requests and forwards them to the right endpoint. +// NOTE: I wasn't really able to test it - I might have messed something up here. +export const RealMealService = { + getOne: (mealId) => + fetch(`/meal/${mealId}`).then((response) => { + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }), + getSimilar: (mealId) => + fetch(`/meal/similar?id=${mealId}`).then((response) => response.json()), + query: (queryContent) => + fetch(`/meal?query=${queryContent}`).then((response) => response.json()), +}; + +// Fake variant, returning hardcoded values. +// No real network requests are made to the service. +// All methods return `Promise`s, for compatibility with the real service. +export const FakeMealService = { + getOne: (mealId) => { + if (mealId == 1) { + return Promise.resolve(fakeMealA); + } else if (mealId == 2) { + return Promise.resolve(fakeMealB); + } else { + return Promise.resolve(null); + } + }, + // If current meal is A, suggest B. Suggest A otherwise. + getSimilar: (mealId) => + Promise.resolve(mealId == fakeMealA.id ? fakeMealB.id : fakeMealA.id), + // Return all known meals, regardless of the query. + query: () => Promise.resolve([fakeMealA, fakeMealB]), +}; + +// NOTE(zajonc): I'm not too creative :/ +const fakeMealA = { + id: 1, + title: "MealA", + description: "MealADescription", + ingredients: ["Ingredient1", "Ingredient2"], + type: "MealAType", +}; + +const fakeMealB = { + id: 2, + title: "MealB", + description: "MealBDescription", + ingredients: ["Ingredient3", "Ingredient4"], + type: "MealBType", +}; diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/main.js b/src/main/webapp/vue-app/meal-assistant-client/src/main.js new file mode 100644 index 0000000..ef8e414 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/main.js @@ -0,0 +1,29 @@ +import Vue from "vue"; +import App from "./App.vue"; +import router from "./router"; +import LoadScript from "vue-plugin-load-script"; +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; + +library.add(faSearch); + +Vue.component("font-awesome-icon", FontAwesomeIcon); + +Vue.use(LoadScript); + +Vue.config.productionTip = false; + +// NOTE(zajonc): do not include the key - it's inactive anyway (as the time of writing). +// Requests to the places API will, unfortunately, fail, but the map will load correctly. +// That should do for UI purposes. +Vue.loadScript("https://maps.googleapis.com/maps/api/js?libraries=places") + .catch((err) => { + console.error(`Failed fetching google maps script: ${err}`); + }) + .then(() => { + new Vue({ + router, + render: (h) => h(App), + }).$mount("#app"); + }); diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/router/index.js b/src/main/webapp/vue-app/meal-assistant-client/src/router/index.js new file mode 100644 index 0000000..16bb83f --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/router/index.js @@ -0,0 +1,43 @@ +import Vue from "vue"; +import VueRouter from "vue-router"; +import Home from "../views/Home.vue"; +import SearchResults from "../views/SearchResults.vue"; +import MealDetail from "../views/MealDetail.vue"; + +Vue.use(VueRouter); + +// A list of views, each associated with a path. +// The router will look at the current path (e.g. /meal/4) and render the first matching +// route whose `path` matches the url. +// See https://stackoverflow.com/a/10473302/7742560 for a brief explanation of Client Side Routing. +// TODO: define remaining routes. +const routes = [ + { + // The `:id` will automagically read the remaining part of the url and + // assign it to a variable stored under `$route.params.id`. + // For example, if we enter /meal/5, id will be set to 5. + path: "/meal/:id", + name: "MealDetail", + component: MealDetail, + }, + { + path: "/search-results/", + name: "SearchResults", + component: SearchResults, + }, + { + path: "/", + name: "Home", + component: Home, + }, +]; + +// Some vue-router boilerplate. +// NOTE(zajonc): to be honest, I don't really know what this bit does. It's been autogenerated. +const router = new VueRouter({ + mode: "history", + base: process.env.BASE_URL, + routes, +}); + +export default router; diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/views/Home.vue b/src/main/webapp/vue-app/meal-assistant-client/src/views/Home.vue new file mode 100644 index 0000000..23e207e --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/views/Home.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/views/MealDetail.vue b/src/main/webapp/vue-app/meal-assistant-client/src/views/MealDetail.vue new file mode 100644 index 0000000..da1d334 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/views/MealDetail.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/src/views/SearchResults.vue b/src/main/webapp/vue-app/meal-assistant-client/src/views/SearchResults.vue new file mode 100644 index 0000000..b26d06b --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/src/views/SearchResults.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/main/webapp/vue-app/meal-assistant-client/tests/component/component/app.spec.js b/src/main/webapp/vue-app/meal-assistant-client/tests/component/component/app.spec.js new file mode 100644 index 0000000..9ef18e1 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/tests/component/component/app.spec.js @@ -0,0 +1,40 @@ +import { render, fireEvent } from "@testing-library/vue"; +import App from "@/App.vue"; +import SearchResults from "@/views/SearchResults.vue"; +import Home from "@/views/Home.vue"; + +// See https://testing-library.com/docs/vue-testing-library/examples for the testing API. +test("renders the app", async () => { + const { findByText, getByText, getByPlaceholderText, getByRole } = render( + App, + { + routes: [ + { + path: "/search-results/", + name: "SearchResults", + component: SearchResults, + }, + { + path: "/", + name: "Home", + component: Home, + }, + ], + stubs: ["font-awesome-icon"], + } + ); + + // Check that the header's there. + // The `/` characters mean we're using a [Regular Expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). + // Long story short, they're a bit more flexible than regular strings. + getByText(/Meal Assistant/); + + const searchBox = getByPlaceholderText(/search/i); + await fireEvent.update(searchBox, "some meal"); + await fireEvent.click(getByRole("button")); + + // Use `findBy` instead of `getBy`. + // The main difference is that it waits for up to 1 second for the element to appear. + // Since the results are returned by a Promise, it takes some time for them to appear. + await findByText(/MealADescription/i); +}); diff --git a/src/main/webapp/vue-app/meal-assistant-client/tests/unit/logic/helpers.spec.js b/src/main/webapp/vue-app/meal-assistant-client/tests/unit/logic/helpers.spec.js new file mode 100644 index 0000000..59fe439 --- /dev/null +++ b/src/main/webapp/vue-app/meal-assistant-client/tests/unit/logic/helpers.spec.js @@ -0,0 +1,13 @@ +import { capitalizeItems } from "@/logic/helpers.js"; + +describe("capitalizeItems", () => { + it("handles one word", () => { + expect(capitalizeItems("word")).toMatch("Word"); + }); + + it("handles one sentence", () => { + expect(capitalizeItems("multi word sentence")).toMatch( + "Multi Word Sentence" + ); + }); +});