Skip to content

Commit

Permalink
Merge b5df30e into 218d2f7
Browse files Browse the repository at this point in the history
  • Loading branch information
mbland committed Jan 13, 2024
2 parents 218d2f7 + b5df30e commit f140824
Show file tree
Hide file tree
Showing 23 changed files with 1,249 additions and 283 deletions.
17 changes: 12 additions & 5 deletions strcalc/src/main/frontend/.eslintrc
@@ -1,8 +1,8 @@
{
"env" : {
"browser" : true,
"env": {
"browser": true,
"node": true,
"es2023" : true
"es2023": true
},
"parserOptions": {
"ecmaVersion": "latest",
Expand All @@ -15,7 +15,7 @@
],
"extends": [
"eslint:recommended",
"plugin:jsdoc/recommended"
"plugin:jsdoc/recommended-typescript-flavor-error"
],
"overrides": [
{
Expand All @@ -26,7 +26,7 @@
]
}
],
"rules" : {
"rules": {
"@stylistic/js/comma-dangle": [
"error", "never"
],
Expand All @@ -51,5 +51,12 @@
"no-console": [
"error", { "allow": [ "warn", "error" ]}
]
},
"settings": {
"jsdoc": {
"preferredTypes": {
"Object": "object"
}
}
}
}
2 changes: 1 addition & 1 deletion strcalc/src/main/frontend/ci/vitest.config.browser.js
@@ -1,5 +1,5 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig, { buildDir } from '../vite.config'
import viteConfig, { buildDir } from '../vite.config.js'

export default mergeConfig(viteConfig, defineConfig({
test: {
Expand Down
2 changes: 1 addition & 1 deletion strcalc/src/main/frontend/ci/vitest.config.js
@@ -1,5 +1,5 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from '../vite.config'
import viteConfig from '../vite.config.js'

export default mergeConfig(viteConfig, defineConfig({
test: {
Expand Down
3 changes: 2 additions & 1 deletion strcalc/src/main/frontend/components/app.js
Expand Up @@ -21,7 +21,8 @@ export default class App {
* demonstrate how to design much larger applications for testability.
* @param {object} params - parameters made available to all initializers
* @param {Element} params.appElem - parent Element containing all components
* @param {object} params.calculators - calculator implementations
* @param {import('./calculators.js').StrCalcDescriptors} params.calculators -
* calculator implementations
*/
init(params) {
// In this example application, none of the components depend on one
Expand Down
17 changes: 11 additions & 6 deletions strcalc/src/main/frontend/components/app.test.js
Expand Up @@ -6,17 +6,23 @@
*/
import App from './app.js'
import { afterAll, afterEach, describe, expect, test } from 'vitest'
import StringCalculatorPage from '../test/page'
import StringCalculatorPage from '../test/page.js'

// @vitest-environment jsdom
describe('initial state after calling App.init()', () => {
const page = StringCalculatorPage.new()
/** @type {import('./calculators.js').StrCalcCallback} */
// eslint-disable-next-line no-unused-vars
const implStub = async _ => ({})

/** @type {import('./calculators.js').StrCalcDescriptors} */
const calculators = {
'first': { label: 'First calculator', impl: null },
'second': { label: 'Second calculator', impl: null },
'third': { label: 'Third calculator', impl: null }
'first': { label: 'First calculator', impl: implStub },
'second': { label: 'Second calculator', impl: implStub },
'third': { label: 'Third calculator', impl: implStub }
}

const page = StringCalculatorPage.new()

afterEach(() => page.clear())
afterAll(() => page.remove())

Expand All @@ -28,4 +34,3 @@ describe('initial state after calling App.init()', () => {
expect(e.href).toContain('%22Hello,_World!%22')
})
})

43 changes: 34 additions & 9 deletions strcalc/src/main/frontend/components/calculator.js
Expand Up @@ -11,29 +11,54 @@ export default class Calculator {
* Initializes the Calculator within the document.
* @param {object} params - parameters made available to all initializers
* @param {Element} params.appElem - parent Element containing all components
* @param {object} params.calculators - calculator implementations
* @param {import('./calculators.js').StrCalcDescriptors} params.calculators -
* calculator implementations
* @param {Function} [params.instantiate] - alternative template instantiation
* function for testing
* @returns {void}
*/
init({ appElem, calculators }) {
init({ appElem, calculators, instantiate = Template }) {
const calcOptions = Object.entries(calculators)
.map(([k, v]) => ({ value: k, label: v.label }))
const t = Template({ calcOptions })
const t = instantiate({ calcOptions })
const [ form, resultElem ] = t.children

appElem.appendChild(t)
document.querySelector('#numbers').focus()

/** @type {(HTMLInputElement | null)} */
const numbers = document.querySelector('#numbers')
if (numbers === null) return console.error('missing numbers input')
numbers.focus()

form.addEventListener(
'submit', e => Calculator.#submitRequest(e, resultElem, calculators)
'submit',
/** @param {Event} e - form submit event */
e => {Calculator.#submitRequest(e, resultElem, calculators)}
)
}

// https://simonplend.com/how-to-use-fetch-to-post-form-data-as-json-to-your-api/
/**
* @param {Event} event - form submit event
* @param {Element} resultElem - element into which to write the result
* @param {import('./calculators.js').StrCalcDescriptors} calculators -
* calculator implementations
* @returns {Promise<void>}
* @see https://simonplend.com/how-to-use-fetch-to-post-form-data-as-json-to-your-api/
*/
static async #submitRequest(event, resultElem, calculators) {
event.preventDefault()

const form = event.currentTarget
const form = /** @type {HTMLFormElement} */ (event.currentTarget)
const data = new FormData(form)
const selected = form.querySelector('input[name="impl"]:checked').value

/** @type {(HTMLInputElement | null)} */
const implInput = form.querySelector('input[name="impl"]:checked')
if (implInput === null) return console.error('missing implementation input')
const selected = implInput.value

/** @type {(HTMLParagraphElement | null)} */
const result = resultElem.querySelector('p')
if (result === null) return console.error('missing result element')

// None of the backends need the 'impl' parameter, and the Java backend
// will return a 500 if we send it.
Expand All @@ -43,7 +68,7 @@ export default class Calculator {
const response = await calculators[selected].impl(data)
result.textContent = `Result: ${response.result}`
} catch (err) {
result.textContent = err
result.textContent = /** @type {any} */ (err)
}
}
}
79 changes: 75 additions & 4 deletions strcalc/src/main/frontend/components/calculator.test.js
Expand Up @@ -5,32 +5,56 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

import Calculator from './calculator'
import Calculator from './calculator.js'
import Template from './calculator.hbs'
import { afterAll, afterEach, describe, expect, test, vi } from 'vitest'
import StringCalculatorPage from '../test/page'
import StringCalculatorPage from '../test/page.js'

// @vitest-environment jsdom
describe('Calculator', () => {
const page = StringCalculatorPage.new()

const setup = () => {
const postFormData = vi.fn()
/** @type {import('./calculators.js').StrCalcDescriptors} */
const calculators = {
'api': { label: 'API', impl: postFormData },
'browser': { label: 'Browser', impl: () => {} }
'browser': { label: 'Browser', impl: vi.fn() }
}

new Calculator().init({ appElem: page.appElem, calculators })
return { page, postFormData }
}

const setupConsoleErrorSpy = () => {
const consoleSpy = vi.spyOn(console, 'error')
.mockImplementationOnce(() => {})

return {
consoleSpy,
loggedError: () => {
const lastCall = consoleSpy.mock.lastCall
if (!lastCall) throw new Error('error not logged')
return lastCall
}
}
}

/**
* @param {string} numbersString - input to the StringCalculator
* @returns {FormData} - form data to submit to the implementation
*/
const expectedFormData = (numbersString) => {
const data = new FormData()
data.append('numbers', numbersString)
return data
}

afterEach(() => page.clear())
afterEach(() => {
vi.restoreAllMocks()
page.clear()
})

afterAll(() => page.remove())

test('renders form and result placeholder', async () => {
Expand Down Expand Up @@ -59,4 +83,51 @@ describe('Calculator', () => {
await expect(result).resolves.toBe('Error: D\'oh!')
expect(postFormData).toHaveBeenCalledWith(expectedFormData('2,2'))
})

test('logs error if missing numbers input element', async () => {
const { loggedError } = setupConsoleErrorSpy()
/** @type {import('./calculators.js').StrCalcDescriptors} */
const calculators = {}
/**
* @param {any} context - init parameters for template
* @returns {DocumentFragment} - template elements without #numbers element
*/
const BadTemplate = (context) => {
const t = Template({ context })
const [ form ] = t.children
const input = form.querySelector('#numbers')

if (input !== null) input.remove()
return t
}

new Calculator().init(
{ appElem: page.appElem, calculators, instantiate: BadTemplate }
)

expect(await vi.waitFor(loggedError))
.toStrictEqual(['missing numbers input'])
})

test('logs error if missing implementation input element', async () => {
const { page } = setup()
const { loggedError } = setupConsoleErrorSpy()

page.impl().remove()
page.enterValueAndSubmit('2,2')

expect(await vi.waitFor(loggedError))
.toStrictEqual(['missing implementation input'])
})

test('logs error if missing result element', async () => {
const { page } = setup()
const { loggedError } = setupConsoleErrorSpy()

page.result().remove()
page.enterValueAndSubmit('2,2')

expect(await vi.waitFor(loggedError))
.toStrictEqual(['missing result element'])
})
})
37 changes: 34 additions & 3 deletions strcalc/src/main/frontend/components/calculators.js
Expand Up @@ -3,27 +3,58 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/* global STRCALC_BACKEND */

import { postFormData } from './request.js'

export const DEFAULT_ENDPOINT = './add'

const backendUrl = () => globalThis.STRCALC_BACKEND ?
new URL(DEFAULT_ENDPOINT, globalThis.STRCALC_BACKEND).toString() :
const backendUrl = () => STRCALC_BACKEND ?
new URL(DEFAULT_ENDPOINT, STRCALC_BACKEND).toString() :
DEFAULT_ENDPOINT

const backendCalculator = async (data)=> postFormData(backendUrl(), data)
/**
* @typedef {object} StrCalcPayload
* @property {number} [result] - the result of the calculation
* @property {string} [error] - error message if the request failed
*/

/**
* Function that invokes a specific String Calculator implementation
* @callback StrCalcCallback
* @param {FormData} data - form data providing String Calculator input
* @returns {Promise<StrCalcPayload>} - the String Calculator result
*/

/**
* Posts the String Calculator input to the backend implementation
* @type {StrCalcCallback}
*/
const backendCalculator = async (data) => postFormData(backendUrl(), data)

/**
* Returns an error as a placeholder for an in-browser StringCalculator
* @type {StrCalcCallback}
*/
const tempCalculator = async (data) => Promise.reject(new Error(
`Temporary in-browser calculator received: "${data.get('numbers')}"`
))

/**
* Describes a specific StringCalculator implementation
* @typedef {object} StrCalcDescriptor
* @property {string} label - descriptive name describing the implementation
* @property {StrCalcCallback} impl - callback invoking StringCalculator impl
*/

/**
* Collection of production String Calculator implementations
*
* Each implementation takes a FormData instance containing only a
* 'numbers' field as its single argument.
* @typedef {Object.<string, StrCalcDescriptor>} StrCalcDescriptors
*/
/** @type {StrCalcDescriptors} */
export default {
'api': { label: 'Tomcat backend API (Java)', impl: backendCalculator },
'browser': { label: 'In-browser (JavaScript)', impl: tempCalculator }
Expand Down
14 changes: 9 additions & 5 deletions strcalc/src/main/frontend/components/calculators.test.js
Expand Up @@ -5,12 +5,16 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

import { default as calculators, DEFAULT_ENDPOINT } from './calculators'
import { default as calculators, DEFAULT_ENDPOINT } from './calculators.js'
import { afterEach, describe, expect, test, vi } from 'vitest'
import setupFetchStub from '../test/fetch-stub'
import { postOptions } from './request'
import setupFetchStub from '../test/fetch-stub.js'
import { postOptions } from './request.js'

describe('calculators', () => {
/**
* @param {string} numbersStr - input to the String Calculator
* @returns {FormData} - form data to submit to the String Calculator
*/
const setupData = (numbersStr) => {
const data = new FormData()
data.append('numbers', numbersStr)
Expand All @@ -22,7 +26,7 @@ describe('calculators', () => {
describe('defaultPost', () => {
test('posts same server by default', async () => {
const data = setupData('2,2')
const fetchStub = setupFetchStub(JSON.stringify({ result: 5 }))
const fetchStub = setupFetchStub({ result: 5 })

await expect(calculators.api.impl(data)).resolves.toEqual({ result: 5 })
expect(fetchStub).toHaveBeenCalledWith(
Expand All @@ -31,7 +35,7 @@ describe('calculators', () => {

test('posts to globalThis.STRCALC_BACKEND', async () => {
const data = setupData('2,2')
const fetchStub = setupFetchStub(JSON.stringify({ result: 5 }))
const fetchStub = setupFetchStub({ result: 5 })
vi.stubGlobal('STRCALC_BACKEND', 'http://localhost:8080/strcalc/')

await expect(calculators.api.impl(data)).resolves.toEqual({ result: 5 })
Expand Down

0 comments on commit f140824

Please sign in to comment.