From 304f89afae854fd75aded2a7893e23d36dd6aa52 Mon Sep 17 00:00:00 2001 From: Derk-Jan Karrenbeld Date: Sat, 4 Sep 2021 21:24:26 +0200 Subject: [PATCH] Add exemplar analyzers (#116) * Add exemplar fixtures * Enable exemplar analyzers * Remove fruit-picker from list --- CHANGELOG.md | 21 ++ package.json | 2 +- src/analyzers/Autoload.ts | 27 ++ test/analyzers/__exemplar/exemplar.ts | 62 ++++ .../amusement-park/exemplar/.meta/config.json | 11 + .../amusement-park/exemplar/.meta/exemplar.js | 66 ++++ .../amusement-park/exemplar/amusement-park.js | 66 ++++ .../exemplar/amusement-park.spec.js | 197 ++++++++++++ .../amusement-park/exemplar/global.d.ts | 7 + .../bird-watcher/exemplar/.meta/config.json | 11 + .../bird-watcher/exemplar/.meta/exemplar.js | 49 +++ .../bird-watcher/exemplar/bird-watcher.js | 49 +++ .../exemplar/bird-watcher.spec.js | 80 +++++ .../exemplar/.meta/config.json | 11 + .../exemplar/.meta/exemplar.js | 74 +++++ .../exemplar/coordinate-transformation.js | 74 +++++ .../coordinate-transformation.spec.js | 116 +++++++ .../exemplar/.meta/config.json | 11 + .../exemplar/.meta/exemplar.js | 67 +++++ .../exemplar/enchantments.js | 67 +++++ .../exemplar/enchantments.spec.js | 111 +++++++ .../exemplar/.meta/config.json | 12 + .../exemplar/.meta/design.md | 28 ++ .../exemplar/.meta/exemplar.js | 27 ++ .../exemplar/enchantments.js | 27 ++ .../exemplar/enchantments.spec.js | 87 ++++++ .../exemplar/global.d.ts | 5 + .../exemplar/.meta/config.json | 11 + .../exemplar/.meta/design.md | 21 ++ .../exemplar/.meta/exemplar.js | 102 +++++++ .../exemplar/enchantments.js | 102 +++++++ .../exemplar/enchantments.spec.js | 283 ++++++++++++++++++ .../exemplar/.meta/config.json | 12 + .../exemplar/.meta/design.md | 37 +++ .../exemplar/.meta/exemplar.js | 41 +++ .../exemplar/enchantments.js | 41 +++ .../exemplar/enchantments.spec.js | 57 ++++ .../exemplar/.meta/config.json | 11 + .../exemplar/.meta/design.md | 32 ++ .../exemplar/.meta/exemplar.js | 92 ++++++ .../exemplar/enchantments.js | 92 ++++++ .../exemplar/enchantments.spec.js | 175 +++++++++++ .../exemplar/.meta/config.json | 10 + .../factory-sensors/exemplar/.meta/design.md | 30 ++ .../exemplar/.meta/exemplar.js | 68 +++++ .../exemplar/factory-sensors.js | 68 +++++ .../exemplar/factory-sensors.spec.js | 115 +++++++ .../exemplar/freelancer-rates.js | 2 +- .../fruit-picker/exemplar/.meta/config.json | 10 + .../fruit-picker/exemplar/.meta/design.md | 27 ++ .../fruit-picker/exemplar/.meta/env.d.ts | 4 + .../fruit-picker/exemplar/.meta/exemplar.js | 49 +++ .../fruit-picker/exemplar/fruit-picker.js | 50 ++++ .../exemplar/fruit-picker.spec.js | 122 ++++++++ .../fruit-picker/exemplar/global.d.ts | 29 ++ test/fixtures/fruit-picker/exemplar/grocer.js | 70 +++++ .../exemplar/.meta/config.json | 11 + .../high-score-board/exemplar/.meta/design.md | 94 ++++++ .../exemplar/.meta/exemplar.js | 75 +++++ .../high-score-board/exemplar/global.d.ts | 4 + .../exemplar/high-score-board.js | 75 +++++ .../exemplar/high-score-board.spec.js | 144 +++++++++ .../lasagna-master/exemplar/.meta/config.json | 12 + .../lasagna-master/exemplar/.meta/design.md | 46 +++ .../lasagna-master/exemplar/.meta/exemplar.js | 91 ++++++ .../lasagna-master/exemplar/global.d.ts | 4 + .../lasagna-master/exemplar/lasagna-master.js | 91 ++++++ .../exemplar/lasagna-master.spec.js | 241 +++++++++++++++ .../lucky-numbers/exemplar/.meta/config.json | 11 + .../lucky-numbers/exemplar/.meta/design.md | 34 +++ .../lucky-numbers/exemplar/.meta/exemplar.js | 46 +++ .../lucky-numbers/exemplar/lucky-numbers.js | 46 +++ .../exemplar/lucky-numbers.spec.js | 54 ++++ .../mixed-juices/exemplar/.meta/config.json | 11 + .../mixed-juices/exemplar/.meta/design.md | 90 ++++++ .../mixed-juices/exemplar/.meta/exemplar.js | 79 +++++ .../mixed-juices/exemplar/mixed-juices.js | 79 +++++ .../exemplar/mixed-juices.spec.js | 113 +++++++ .../nullability/exemplar/.meta/config.json | 11 + .../nullability/exemplar/.meta/exemplar.js | 17 ++ .../nullability/exemplar/nullability.js | 17 ++ .../nullability/exemplar/nullability.spec.js | 31 ++ .../ozans-playlist/exemplar/.meta/config.json | 10 + .../ozans-playlist/exemplar/.meta/design.md | 45 +++ .../ozans-playlist/exemplar/.meta/exemplar.js | 70 +++++ .../ozans-playlist/exemplar/ozans-playlist.js | 70 +++++ .../exemplar/ozans-playlist.spec.js | 100 +++++++ .../pizza-order/exemplar/.meta/config.json | 11 + .../pizza-order/exemplar/.meta/design.md | 27 ++ .../pizza-order/exemplar/.meta/env.d.ts | 4 + .../pizza-order/exemplar/.meta/exemplar.js | 47 +++ .../fixtures/pizza-order/exemplar/global.d.ts | 3 + .../pizza-order/exemplar/pizza-order.js | 46 +++ .../pizza-order/exemplar/pizza-order.spec.js | 171 +++++++++++ .../exemplar/.meta/config.json | 12 + .../exemplar/.meta/env.d.ts | 4 + .../exemplar/.meta/exemplar.alternative.js | 115 +++++++ .../exemplar/.meta/exemplar.js | 109 +++++++ .../translation-service/exemplar/api.js | 100 +++++++ .../translation-service/exemplar/errors.js | 27 ++ .../translation-service/exemplar/global.d.ts | 21 ++ .../translation-service/exemplar/service.js | 109 +++++++ .../exemplar/service.spec.js | 205 +++++++++++++ .../exemplar/.meta/config.json | 11 + .../vehicle-purchase/exemplar/.meta/design.md | 57 ++++ .../exemplar/.meta/exemplar.js | 55 ++++ .../exemplar/vehicle-purchase.js | 55 ++++ .../exemplar/vehicle-purchase.spec.js | 74 +++++ 108 files changed, 6211 insertions(+), 2 deletions(-) create mode 100644 test/analyzers/__exemplar/exemplar.ts create mode 100644 test/fixtures/amusement-park/exemplar/.meta/config.json create mode 100644 test/fixtures/amusement-park/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/amusement-park/exemplar/amusement-park.js create mode 100644 test/fixtures/amusement-park/exemplar/amusement-park.spec.js create mode 100644 test/fixtures/amusement-park/exemplar/global.d.ts create mode 100644 test/fixtures/bird-watcher/exemplar/.meta/config.json create mode 100644 test/fixtures/bird-watcher/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/bird-watcher/exemplar/bird-watcher.js create mode 100644 test/fixtures/bird-watcher/exemplar/bird-watcher.spec.js create mode 100644 test/fixtures/coordinate-transformation/exemplar/.meta/config.json create mode 100644 test/fixtures/coordinate-transformation/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.js create mode 100644 test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.spec.js create mode 100644 test/fixtures/elyses-analytic-enchantments/exemplar/.meta/config.json create mode 100644 test/fixtures/elyses-analytic-enchantments/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.js create mode 100644 test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.spec.js create mode 100644 test/fixtures/elyses-destructured-enchantments/exemplar/.meta/config.json create mode 100644 test/fixtures/elyses-destructured-enchantments/exemplar/.meta/design.md create mode 100644 test/fixtures/elyses-destructured-enchantments/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.js create mode 100644 test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.spec.js create mode 100644 test/fixtures/elyses-destructured-enchantments/exemplar/global.d.ts create mode 100644 test/fixtures/elyses-enchantments/exemplar/.meta/config.json create mode 100644 test/fixtures/elyses-enchantments/exemplar/.meta/design.md create mode 100644 test/fixtures/elyses-enchantments/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/elyses-enchantments/exemplar/enchantments.js create mode 100644 test/fixtures/elyses-enchantments/exemplar/enchantments.spec.js create mode 100644 test/fixtures/elyses-looping-enchantments/exemplar/.meta/config.json create mode 100644 test/fixtures/elyses-looping-enchantments/exemplar/.meta/design.md create mode 100644 test/fixtures/elyses-looping-enchantments/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/elyses-looping-enchantments/exemplar/enchantments.js create mode 100644 test/fixtures/elyses-looping-enchantments/exemplar/enchantments.spec.js create mode 100644 test/fixtures/elyses-transformative-enchantments/exemplar/.meta/config.json create mode 100644 test/fixtures/elyses-transformative-enchantments/exemplar/.meta/design.md create mode 100644 test/fixtures/elyses-transformative-enchantments/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.js create mode 100644 test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.spec.js create mode 100644 test/fixtures/factory-sensors/exemplar/.meta/config.json create mode 100644 test/fixtures/factory-sensors/exemplar/.meta/design.md create mode 100644 test/fixtures/factory-sensors/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/factory-sensors/exemplar/factory-sensors.js create mode 100644 test/fixtures/factory-sensors/exemplar/factory-sensors.spec.js create mode 100644 test/fixtures/fruit-picker/exemplar/.meta/config.json create mode 100644 test/fixtures/fruit-picker/exemplar/.meta/design.md create mode 100644 test/fixtures/fruit-picker/exemplar/.meta/env.d.ts create mode 100644 test/fixtures/fruit-picker/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/fruit-picker/exemplar/fruit-picker.js create mode 100644 test/fixtures/fruit-picker/exemplar/fruit-picker.spec.js create mode 100644 test/fixtures/fruit-picker/exemplar/global.d.ts create mode 100644 test/fixtures/fruit-picker/exemplar/grocer.js create mode 100644 test/fixtures/high-score-board/exemplar/.meta/config.json create mode 100644 test/fixtures/high-score-board/exemplar/.meta/design.md create mode 100644 test/fixtures/high-score-board/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/high-score-board/exemplar/global.d.ts create mode 100644 test/fixtures/high-score-board/exemplar/high-score-board.js create mode 100644 test/fixtures/high-score-board/exemplar/high-score-board.spec.js create mode 100644 test/fixtures/lasagna-master/exemplar/.meta/config.json create mode 100644 test/fixtures/lasagna-master/exemplar/.meta/design.md create mode 100644 test/fixtures/lasagna-master/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/lasagna-master/exemplar/global.d.ts create mode 100644 test/fixtures/lasagna-master/exemplar/lasagna-master.js create mode 100644 test/fixtures/lasagna-master/exemplar/lasagna-master.spec.js create mode 100644 test/fixtures/lucky-numbers/exemplar/.meta/config.json create mode 100644 test/fixtures/lucky-numbers/exemplar/.meta/design.md create mode 100644 test/fixtures/lucky-numbers/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/lucky-numbers/exemplar/lucky-numbers.js create mode 100644 test/fixtures/lucky-numbers/exemplar/lucky-numbers.spec.js create mode 100644 test/fixtures/mixed-juices/exemplar/.meta/config.json create mode 100644 test/fixtures/mixed-juices/exemplar/.meta/design.md create mode 100644 test/fixtures/mixed-juices/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/mixed-juices/exemplar/mixed-juices.js create mode 100644 test/fixtures/mixed-juices/exemplar/mixed-juices.spec.js create mode 100644 test/fixtures/nullability/exemplar/.meta/config.json create mode 100644 test/fixtures/nullability/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/nullability/exemplar/nullability.js create mode 100644 test/fixtures/nullability/exemplar/nullability.spec.js create mode 100644 test/fixtures/ozans-playlist/exemplar/.meta/config.json create mode 100644 test/fixtures/ozans-playlist/exemplar/.meta/design.md create mode 100644 test/fixtures/ozans-playlist/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/ozans-playlist/exemplar/ozans-playlist.js create mode 100644 test/fixtures/ozans-playlist/exemplar/ozans-playlist.spec.js create mode 100644 test/fixtures/pizza-order/exemplar/.meta/config.json create mode 100644 test/fixtures/pizza-order/exemplar/.meta/design.md create mode 100644 test/fixtures/pizza-order/exemplar/.meta/env.d.ts create mode 100644 test/fixtures/pizza-order/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/pizza-order/exemplar/global.d.ts create mode 100644 test/fixtures/pizza-order/exemplar/pizza-order.js create mode 100644 test/fixtures/pizza-order/exemplar/pizza-order.spec.js create mode 100644 test/fixtures/translation-service/exemplar/.meta/config.json create mode 100644 test/fixtures/translation-service/exemplar/.meta/env.d.ts create mode 100644 test/fixtures/translation-service/exemplar/.meta/exemplar.alternative.js create mode 100644 test/fixtures/translation-service/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/translation-service/exemplar/api.js create mode 100644 test/fixtures/translation-service/exemplar/errors.js create mode 100644 test/fixtures/translation-service/exemplar/global.d.ts create mode 100644 test/fixtures/translation-service/exemplar/service.js create mode 100644 test/fixtures/translation-service/exemplar/service.spec.js create mode 100644 test/fixtures/vehicle-purchase/exemplar/.meta/config.json create mode 100644 test/fixtures/vehicle-purchase/exemplar/.meta/design.md create mode 100644 test/fixtures/vehicle-purchase/exemplar/.meta/exemplar.js create mode 100644 test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.js create mode 100644 test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 07bac50b..3b1c30b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.16.0 + +- Add exemplary analyzer for `amusement-park` +- Add exemplary analyzer for `bird-watcher` +- Add exemplary analyzer for `coordinate-transformation` +- Add exemplary analyzer for `elyses-analytic-enchantments` +- Add exemplary analyzer for `elyses-destructured-enchantments` +- Add exemplary analyzer for `elyses-enchantments` +- Add exemplary analyzer for `elyses-looping-enchantments` +- Add exemplary analyzer for `elyses-transformative-enchantments` +- Add exemplary analyzer for `factory-sensors` +- Add exemplary analyzer for `high-score-board` +- Add exemplary analyzer for `lasagna-master` +- Add exemplary analyzer for `lucky-numbers` +- Add exemplary analyzer for `mixed-juices` +- Add exemplary analyzer for `nullability` +- Add exemplary analyzer for `ozans-playlist` +- Add exemplary analyzer for `pizza-order` +- Add exemplary analyzer for `translation-service` +- Add exemplary analyzer for `vehicle-purchase` + ## 0.15.0 - Allow non-optimal constants in `resistor-color-duo` diff --git a/package.json b/package.json index 0010f3ad..037964dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@exercism/javascript-analyzer", - "version": "0.15.0", + "version": "0.16.0", "description": "Exercism analyzer for javascript", "repository": "https://github.com/exercism/javascript-analyzer", "author": "Derk-Jan Karrenbeld ", diff --git a/src/analyzers/Autoload.ts b/src/analyzers/Autoload.ts index f7eb2fc3..9845b87d 100644 --- a/src/analyzers/Autoload.ts +++ b/src/analyzers/Autoload.ts @@ -42,6 +42,33 @@ function autoload(exercise: Readonly): ReturnType { path.join(__dirname, 'concept', exercise.slug, 'index'), ] + // These exercises can also defer to the exemplar analyzer only + if ( + [ + 'amusement-park', + 'bird-watcher', + 'coordinate-transformation', + 'elyses-analytic-enchantments', + 'elyses-destructured-enchantments', + 'elyses-enchantments', + 'elyses-looping-enchantments', + 'elyses-transformative-enchantments', + 'factory-sensors', + // 'fruit-picker', + 'high-score-board', + 'lasagna-master', + 'lucky-numbers', + 'mixed-juices', + 'nullability', + 'ozans-playlist', + 'pizza-order', + 'translation-service', + 'vehicle-purchase', + ].includes(exercise.slug) + ) { + modulePaths.push(path.join(__dirname, 'concept', '__exemplar', 'index')) + } + const results = modulePaths.map((modulePath) => { try { return require(modulePath) diff --git a/test/analyzers/__exemplar/exemplar.ts b/test/analyzers/__exemplar/exemplar.ts new file mode 100644 index 00000000..19e7993f --- /dev/null +++ b/test/analyzers/__exemplar/exemplar.ts @@ -0,0 +1,62 @@ +import { DirectoryWithConfigInput } from '@exercism/static-analysis' +import path from 'path' +import { ExemplarAnalyzer } from '~src/analyzers/concept/__exemplar' +import { EXEMPLAR_SOLUTION } from '~src/comments/shared' +import { makeAnalyze, makeOptions } from '~test/helpers/smoke' +;[ + 'amusement-park', + 'bird-watcher', + 'coordinate-transformation', + 'elyses-analytic-enchantments', + 'elyses-destructured-enchantments', + 'elyses-enchantments', + 'elyses-looping-enchantments', + 'elyses-transformative-enchantments', + 'factory-sensors', + // 'fruit-picker', + 'high-score-board', + 'lasagna-master', + 'lucky-numbers', + 'mixed-juices', + 'nullability', + 'ozans-playlist', + 'pizza-order', + 'translation-service', + 'vehicle-purchase', +].forEach((exercise) => { + const inputDir = path.join( + __dirname, + '..', + '..', + 'fixtures', + exercise, + 'exemplar' + ) + + const analyze = makeAnalyze( + () => new ExemplarAnalyzer(), + makeOptions({ + get inputDir(): string { + return inputDir + }, + get exercise(): string { + return exercise + }, + }) + ) + + describe(`When running analysis on ${exercise}`, () => { + it('recognises the exemplar solution', async () => { + const input = new DirectoryWithConfigInput(inputDir) + + const [solution] = await input.read() + const output = await analyze(solution) + + expect(output.comments.length).toBe(1) + expect(output.comments[0].type).toBe('celebratory') + expect(output.comments[0].externalTemplate).toBe( + EXEMPLAR_SOLUTION().externalTemplate + ) + }) + }) +}) diff --git a/test/fixtures/amusement-park/exemplar/.meta/config.json b/test/fixtures/amusement-park/exemplar/.meta/config.json new file mode 100644 index 00000000..dfd9ffda --- /dev/null +++ b/test/fixtures/amusement-park/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Learn about undefined and null by managing visitors and tickets at an amusement park.", + "authors": ["junedev"], + "contributors": [], + "files": { + "solution": ["amusement-park.js"], + "test": ["amusement-park.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["ruby/amusement-park"] +} diff --git a/test/fixtures/amusement-park/exemplar/.meta/exemplar.js b/test/fixtures/amusement-park/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..63d4c193 --- /dev/null +++ b/test/fixtures/amusement-park/exemplar/.meta/exemplar.js @@ -0,0 +1,66 @@ +/// +// @ts-check + +/** + * Creates a new visitor. + * + * @param {string} name + * @param {number} age + * @param {string} ticketId + * @returns {Visitor} the visitor that was created + */ +export function createVisitor(name, age, ticketId) { + return { name, age, ticketId }; +} + +/** + * Revokes a ticket for a visitor. + * + * @param {Visitor} visitor the visitor with an active ticket + * @returns {Visitor} the visitor without a ticket + */ +export function revokeTicket(visitor) { + visitor.ticketId = null; + return visitor; +} + +/** + * Determines the status a ticket has in the ticket tracking object. + * + * @param {Record} tickets + * @param {string} ticketId + * @returns {string} ticket status + */ +export function ticketStatus(tickets, ticketId) { + if (tickets[ticketId] === undefined) { + return 'unknown ticket id'; + } + + if (tickets[ticketId] === null) { + return 'not sold'; + } + + return 'sold to ' + tickets[ticketId]; +} + +/** + * Determines the status a ticket has in the ticket tracking object + * and returns a simplified status message. + * + * @param {Record} tickets + * @param {string} ticketId + * @returns {string} ticket status + */ +export function simpleTicketStatus(tickets, ticketId) { + return tickets[ticketId] ?? 'invalid ticket !!!'; +} + +/** + * Determines the version of the GTC that was signed by the visitor. + * + * @param {VisitorWithGtc} visitor + * @returns {string | undefined} version + */ +export function gtcVersion(visitor) { + return visitor.gtc?.version; +} diff --git a/test/fixtures/amusement-park/exemplar/amusement-park.js b/test/fixtures/amusement-park/exemplar/amusement-park.js new file mode 100644 index 00000000..2fd0f016 --- /dev/null +++ b/test/fixtures/amusement-park/exemplar/amusement-park.js @@ -0,0 +1,66 @@ +/// +// @ts-check + +/** + * Creates a new visitor. + * + * @param {string} name + * @param {number} age + * @param {string} ticketId + * @returns {Visitor} the visitor that was created + */ +export function createVisitor(name, age, ticketId) { + return { name, age, ticketId }; +} + +/** + * Revokes a ticket for a visitor. + * + * @param {Visitor} visitor the visitor with an active ticket + * @returns {Visitor} the visitor without a ticket + */ +export function revokeTicket(visitor) { + visitor.ticketId = null; + return visitor; +} + +/** + * Determines the status a ticket has in the ticket tracking object. + * + * @param {Record} tickets + * @param {string} ticketId + * @returns {string} ticket status + */ +export function ticketStatus(tickets, ticketId) { + if (tickets[ticketId] === undefined) { + return 'unknown ticket id'; + } + + if (tickets[ticketId] === null) { + return 'not sold'; + } + + return 'sold to ' + tickets[ticketId]; +} + +/** + * Determines the status a ticket has in the ticket tracking object + * and returns a simplified status message. + * + * @param {Record} tickets + * @param {string} ticketId + * @returns {string} ticket status + */ +export function simpleTicketStatus(tickets, ticketId) { + return tickets[ticketId] ?? 'invalid ticket !!!'; +} + +/** + * Determines the version of the GTC that was signed by the visitor. + * + * @param {VisitorWithGtc} visitor + * @returns {string | undefined} version + */ +export function gtcVersion(visitor) { + return visitor.gtc?.version; +} diff --git a/test/fixtures/amusement-park/exemplar/amusement-park.spec.js b/test/fixtures/amusement-park/exemplar/amusement-park.spec.js new file mode 100644 index 00000000..6da514de --- /dev/null +++ b/test/fixtures/amusement-park/exemplar/amusement-park.spec.js @@ -0,0 +1,197 @@ +import { + createVisitor, + revokeTicket, + ticketStatus, + simpleTicketStatus, + gtcVersion, +} from './amusement-park'; + +describe('createVisitor', () => { + test('correctly assembles the visitor object', () => { + const expected1 = { name: 'Imran Kudrna', age: 21, ticketId: 'ECMZR67C' }; + const expected2 = { name: 'Nālani Sansone', age: 76, ticketId: 'YX52GB06' }; + const expected3 = { name: 'Bogiła Kalš', age: 55, ticketId: '30Z2OKJP' }; + + expect(createVisitor('Imran Kudrna', 21, 'ECMZR67C')).toEqual(expected1); + expect(createVisitor('Nālani Sansone', 76, 'YX52GB06')).toEqual(expected2); + expect(createVisitor('Bogiła Kalš', 55, '30Z2OKJP')).toEqual(expected3); + }); +}); + +describe('revokeTicket', () => { + test('sets the ticketId to null', () => { + const visitor = { name: 'María Pilar Neri', age: 16, ticketId: 'MFBSF3S2' }; + + const expected = { name: 'María Pilar Neri', age: 16, ticketId: null }; + expect(revokeTicket(visitor)).toEqual(expected); + }); + + test('returns the same object that was passed in', () => { + const visitor = { name: 'Anatoli Traverse', age: 34, ticketId: 'AA5AA01D' }; + + // this follows the suggestion from the Jest docs to avoid a confusing test report + // https://jestjs.io/docs/expect#tobevalue + expect(Object.is(revokeTicket(visitor), visitor)).toBe(true); + }); + + test('does nothing if the ticket was already revoked', () => { + const visitor = { name: 'Min-Ji Chu', age: 51, ticketId: null }; + const actual = revokeTicket(visitor); + + expect(actual).toEqual(visitor); + expect(Object.is(actual, visitor)).toBe(true); + }); +}); + +describe('ticketStatus', () => { + test('correctly identifies that a ticket is not in the tracking object', () => { + expect(ticketStatus(testTickets(), 'Y4KXZOYM')).toBe('unknown ticket id'); + expect(ticketStatus(testTickets(), '8ATQC1ZJ')).toBe('unknown ticket id'); + expect(ticketStatus(testTickets(), 'G833HR8A')).toBe('unknown ticket id'); + }); + + test('correctly identifies that a ticket is not sold', () => { + expect(ticketStatus(testTickets(), 'V42NWRMQ')).toBe('not sold'); + expect(ticketStatus(testTickets(), 'A56MTX8E')).toBe('not sold'); + expect(ticketStatus(testTickets(), 'YEVHK4MC')).toBe('not sold'); + }); + + test('returns the correct string for a ticket that was sold', () => { + const actual1 = ticketStatus(testTickets(), 'QINS6S94'); + expect(actual1).toBe('sold to Hong Hsu'); + + const actual2 = ticketStatus(testTickets(), 'H31SAW5Q'); + expect(actual2).toBe('sold to Lior MacNeil'); + + const actual3 = ticketStatus(testTickets(), 'M9ZTXP89'); + expect(actual3).toBe('sold to Kamani Ybarra'); + }); +}); + +describe('simpleTicketStatus', () => { + test('identifies ticket that are not in the tracking object as invalid', () => { + const expected = 'invalid ticket !!!'; + expect(simpleTicketStatus(testTickets(), 'Y4KXZOYM')).toBe(expected); + expect(simpleTicketStatus(testTickets(), '8ATQC1ZJ')).toBe(expected); + expect(simpleTicketStatus(testTickets(), 'G833HR8A')).toBe(expected); + }); + + test('identifies tickets that are not sold as invalid', () => { + const expected = 'invalid ticket !!!'; + expect(simpleTicketStatus(testTickets(), 'V42NWRMQ')).toBe(expected); + expect(simpleTicketStatus(testTickets(), 'A56MTX8E')).toBe(expected); + expect(simpleTicketStatus(testTickets(), 'YEVHK4MC')).toBe(expected); + }); + + test('returns the visitor name for tickets that were sold', () => { + expect(simpleTicketStatus(testTickets(), 'QINS6S94')).toBe('Hong Hsu'); + expect(simpleTicketStatus(testTickets(), 'H31SAW5Q')).toBe('Lior MacNeil'); + expect(simpleTicketStatus(testTickets(), 'M9ZTXP89')).toBe('Kamani Ybarra'); + }); + + test('tickets with "strange" name values are valid nevertheless', () => { + const tickets = { + B7627X32: '', + XF1X6S2W: 0, + KJJIFFO0: false, + }; + + const unexpected = 'invalid ticket !!!'; + expect(simpleTicketStatus(tickets, 'B7627X32')).not.toEqual(unexpected); + expect(simpleTicketStatus(tickets, 'XF1X6S2W')).not.toEqual(unexpected); + expect(simpleTicketStatus(tickets, 'KJJIFFO0')).not.toEqual(unexpected); + }); +}); + +describe('gtcVersion', () => { + test('determines the GTC version if it is present', () => { + const visitor1 = { + name: 'Zohar Pekkanen', + age: 28, + ticketId: '8DGM3163', + gtc: { + signed: true, + version: '4.2', + }, + }; + + const visitor2 = { + name: 'Fen Van der Hout', + age: 70, + ticketId: 'BMYPNZGT', + gtc: { + signed: true, + version: '1.6', + }, + }; + + expect(gtcVersion(visitor1)).toBe('4.2'); + expect(gtcVersion(visitor2)).toBe('1.6'); + }); + + test('returns nothing if there is no gtc object', () => { + const visitor1 = { + name: 'Xuân Jahoda', + age: 15, + ticketId: 'NZGKELXC', + }; + + const visitor2 = { + name: 'Micha Tót', + age: 49, + ticketId: '3BGCW1G9', + }; + + expect(gtcVersion(visitor1)).toBeUndefined(); + expect(gtcVersion(visitor2)).toBeUndefined(); + }); + + test('returns nothing if there is a gtc object but no gtc version', () => { + const visitor1 = { + name: 'Xuân Jahoda', + age: 15, + ticketId: 'NZGKELXC', + gtc: {}, + }; + + const visitor2 = { + name: 'Micha Tót', + age: 49, + ticketId: '3BGCW1G9', + gtc: { + signed: false, + }, + }; + + expect(gtcVersion(visitor1)).toBeUndefined(); + expect(gtcVersion(visitor2)).toBeUndefined(); + }); + + test('does not modify the visitor object', () => { + const visitor = { + name: 'Zohar Pekkanen', + age: 28, + ticketId: '8DGM3163', + }; + + const expected = { + name: 'Zohar Pekkanen', + age: 28, + ticketId: '8DGM3163', + }; + + gtcVersion(visitor); + expect(visitor).toEqual(expected); + }); +}); + +const testTickets = () => { + return { + QINS6S94: 'Hong Hsu', + V42NWRMQ: null, + A56MTX8E: null, + H31SAW5Q: 'Lior MacNeil', + M9ZTXP89: 'Kamani Ybarra', + YEVHK4MC: null, + }; +}; diff --git a/test/fixtures/amusement-park/exemplar/global.d.ts b/test/fixtures/amusement-park/exemplar/global.d.ts new file mode 100644 index 00000000..cc05139d --- /dev/null +++ b/test/fixtures/amusement-park/exemplar/global.d.ts @@ -0,0 +1,7 @@ +declare type Visitor = { + name: string; + age: number; + ticketId: string | null; +}; + +declare type VisitorWithGtc = Visitor & { gtc?: { version: string } }; diff --git a/test/fixtures/bird-watcher/exemplar/.meta/config.json b/test/fixtures/bird-watcher/exemplar/.meta/config.json new file mode 100644 index 00000000..ec8c67c2 --- /dev/null +++ b/test/fixtures/bird-watcher/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Professionalize counting the birds in your garden with for loops and increment/decrement operators", + "authors": ["junedev"], + "contributors": [], + "files": { + "solution": ["bird-watcher.js"], + "test": ["bird-watcher.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["csharp/bird-watcher"] +} diff --git a/test/fixtures/bird-watcher/exemplar/.meta/exemplar.js b/test/fixtures/bird-watcher/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..616b6c88 --- /dev/null +++ b/test/fixtures/bird-watcher/exemplar/.meta/exemplar.js @@ -0,0 +1,49 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Calculates the total bird count. + * + * @param {number[]} birdsPerDay + * @returns {number} total bird count + */ +export function totalBirdCount(birdsPerDay) { + let total = 0; + for (let i = 0; i < birdsPerDay.length; i++) { + total += birdsPerDay[i]; + } + return total; +} + +/** + * Calculates the total number of birds seen in a specific week. + * + * @param {number[]} birdsPerDay + * @param {number} week + * @returns {number} birds counted in the given week + */ +export function birdsInWeek(birdsPerDay, week) { + let total = 0; + const start = 7 * (week - 1); + for (let i = start; i < start + 7; i++) { + total += birdsPerDay[i]; + } + return total; +} + +/** + * Fixes the counting mistake by increasing the bird count + * by one for every second day. + * + * @param {number[]} birdsPerDay + * @returns {number[]} corrected bird count data + */ +export function fixBirdCountLog(birdsPerDay) { + for (let i = 0; i < birdsPerDay.length; i += 2) { + birdsPerDay[i]++; + } + return birdsPerDay; +} diff --git a/test/fixtures/bird-watcher/exemplar/bird-watcher.js b/test/fixtures/bird-watcher/exemplar/bird-watcher.js new file mode 100644 index 00000000..616b6c88 --- /dev/null +++ b/test/fixtures/bird-watcher/exemplar/bird-watcher.js @@ -0,0 +1,49 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Calculates the total bird count. + * + * @param {number[]} birdsPerDay + * @returns {number} total bird count + */ +export function totalBirdCount(birdsPerDay) { + let total = 0; + for (let i = 0; i < birdsPerDay.length; i++) { + total += birdsPerDay[i]; + } + return total; +} + +/** + * Calculates the total number of birds seen in a specific week. + * + * @param {number[]} birdsPerDay + * @param {number} week + * @returns {number} birds counted in the given week + */ +export function birdsInWeek(birdsPerDay, week) { + let total = 0; + const start = 7 * (week - 1); + for (let i = start; i < start + 7; i++) { + total += birdsPerDay[i]; + } + return total; +} + +/** + * Fixes the counting mistake by increasing the bird count + * by one for every second day. + * + * @param {number[]} birdsPerDay + * @returns {number[]} corrected bird count data + */ +export function fixBirdCountLog(birdsPerDay) { + for (let i = 0; i < birdsPerDay.length; i += 2) { + birdsPerDay[i]++; + } + return birdsPerDay; +} diff --git a/test/fixtures/bird-watcher/exemplar/bird-watcher.spec.js b/test/fixtures/bird-watcher/exemplar/bird-watcher.spec.js new file mode 100644 index 00000000..3339045c --- /dev/null +++ b/test/fixtures/bird-watcher/exemplar/bird-watcher.spec.js @@ -0,0 +1,80 @@ +import { totalBirdCount, birdsInWeek, fixBirdCountLog } from './bird-watcher'; + +describe('bird watcher', () => { + describe('totalBirdCount', () => { + test('calculates the correct total number of birds', () => { + const birdsPerDay = [9, 0, 8, 4, 5, 1, 3]; + expect(totalBirdCount(birdsPerDay)).toBe(30); + }); + + test('works for a short bird count list', () => { + const birdsPerDay = [2]; + expect(totalBirdCount(birdsPerDay)).toBe(2); + }); + + test('works for a long bird count list', () => { + // prettier-ignore + const birdsPerDay = [2, 8, 4, 1, 3, 5, 0, 4, 1, 6, 0, 3, 0, 1, 5, 4, 1, 1, 2, 6]; + expect(totalBirdCount(birdsPerDay)).toBe(57); + }); + }); + + describe('birdsInWeek', () => { + test('calculates the number of birds in the first week', () => { + const birdsPerDay = [3, 0, 5, 1, 0, 4, 1, 0, 3, 4, 3, 0, 8, 0]; + expect(birdsInWeek(birdsPerDay, 1)).toBe(14); + }); + + test('calculates the number of birds for a week in the middle of the log', () => { + // prettier-ignore + const birdsPerDay = [4, 7, 3, 2, 1, 1, 2, 0, 2, 3, 2, 7, 1, 3, 0, 6, 5, 3, 7, 2, 3]; + expect(birdsInWeek(birdsPerDay, 2)).toBe(18); + }); + + test('works when there is only one week', () => { + const birdsPerDay = [3, 0, 3, 3, 2, 1, 0]; + expect(birdsInWeek(birdsPerDay, 1)).toBe(12); + }); + + test('works for a long bird count list', () => { + const week21 = [2, 0, 1, 4, 1, 3, 0]; + const birdsPerDay = randomArray(20 * 7) + .concat(week21) + .concat(randomArray(10 * 7)); + + expect(birdsInWeek(birdsPerDay, 21)).toBe(11); + }); + }); + + describe('fixBirdCountLog', () => { + test('returns a bird count list with the corrected values', () => { + const birdsPerDay = [3, 0, 5, 1, 0, 4, 1, 0, 3, 4, 3, 0]; + const expected = [4, 0, 6, 1, 1, 4, 2, 0, 4, 4, 4, 0]; + expect(fixBirdCountLog(birdsPerDay)).toEqual(expected); + }); + + test('does not create a new array', () => { + const birdsPerDay = [2, 0, 1, 4, 1, 3, 0]; + + // this follows the suggestion from the Jest docs to avoid a confusing test report + // https://jestjs.io/docs/expect#tobevalue + expect(Object.is(fixBirdCountLog(birdsPerDay), birdsPerDay)).toBe(true); + }); + + test('works for a short bird count list', () => { + expect(fixBirdCountLog([4, 2])).toEqual([5, 2]); + }); + + test('works for a long bird count list', () => { + // prettier-ignore + const birdsPerDay = [2, 8, 4, 1, 3, 5, 0, 4, 1, 6, 0, 3, 0, 1, 5, 4, 1, 1, 2, 6]; + // prettier-ignore + const expected = [3, 8, 5, 1, 4, 5, 1, 4, 2, 6, 1, 3, 1, 1, 6, 4, 2, 1, 3, 6]; + expect(fixBirdCountLog(birdsPerDay)).toEqual(expected); + }); + }); +}); + +function randomArray(length) { + return Array.from({ length: length }, () => Math.floor(Math.random() * 8)); +} diff --git a/test/fixtures/coordinate-transformation/exemplar/.meta/config.json b/test/fixtures/coordinate-transformation/exemplar/.meta/config.json new file mode 100644 index 00000000..cfee8402 --- /dev/null +++ b/test/fixtures/coordinate-transformation/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Practice your knowledge of closures by implementing various coordinate transformations.", + "authors": ["neenjaw"], + "contributors": ["SleeplessByte"], + "files": { + "solution": ["coordinate-transformation.js"], + "test": ["coordinate-transformation.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": [] +} diff --git a/test/fixtures/coordinate-transformation/exemplar/.meta/exemplar.js b/test/fixtures/coordinate-transformation/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..875b5185 --- /dev/null +++ b/test/fixtures/coordinate-transformation/exemplar/.meta/exemplar.js @@ -0,0 +1,74 @@ +/** + * Create a function which returns a function making use of a closure to + * perform a repeatable 2d translation of a coordinate pair. + * + * @param {number} dx the translate x component + * @param {number} dy the translate y component + * + * @returns {function} a function which takes an x, y argument, returns the + * translated coordinate pair in the form [x, y] + */ +export function translate2d(dx, dy) { + return function (x, y) { + return [x + dx, y + dy]; + }; +} + +/** + * Create a function which returns a function making use of a closure to + * perform a repeatable 2d scale of a coordinate pair. + * + * @param {number} sx the amount to scale the x component + * @param {number} sy the amount to scale the y component + * + * @returns {function} a function which takes an x, y argument, returns the + * scaled coordinate pair in the form [x, y] + */ +export function scale2d(sx, sy) { + return function (x, y) { + return [x * sx, y * sy]; + }; +} + +/** + * Create a composition function which returns a function that combines two + * functions to perform a repeatable transformation + * + * @param {function} f the first function to apply + * @param {function} g the second function to apply + * + * @returns {function} a function which takes an x, y argument, returns the + * transformed coordinate pair in the form [x, y] + */ +export function composeTransform(f, g) { + return function (x, y) { + const fResult = f(x, y); + return g(fResult[0], fResult[1]); + }; +} + +/** + * Return a function which memoizes the last result. If arguments are the same, + * then memoized result returned. + * + * @param {function} f the transformation function to memoize, assumes takes two arguments 'x' and 'y' + * + * @returns {function} a function which takes and x, y argument, and will either return the saved result + * if the arguments are the same on subsequent calls, or compute a new result if they are different. + */ +export function memoizeTransform(f) { + let lastX = undefined; + let lastY = undefined; + let lastResult = undefined; + + return function (x, y) { + if (x === lastX && y === lastY) { + return lastResult; + } + + lastX = x; + lastY = y; + lastResult = f(x, y); + return lastResult; + }; +} diff --git a/test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.js b/test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.js new file mode 100644 index 00000000..875b5185 --- /dev/null +++ b/test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.js @@ -0,0 +1,74 @@ +/** + * Create a function which returns a function making use of a closure to + * perform a repeatable 2d translation of a coordinate pair. + * + * @param {number} dx the translate x component + * @param {number} dy the translate y component + * + * @returns {function} a function which takes an x, y argument, returns the + * translated coordinate pair in the form [x, y] + */ +export function translate2d(dx, dy) { + return function (x, y) { + return [x + dx, y + dy]; + }; +} + +/** + * Create a function which returns a function making use of a closure to + * perform a repeatable 2d scale of a coordinate pair. + * + * @param {number} sx the amount to scale the x component + * @param {number} sy the amount to scale the y component + * + * @returns {function} a function which takes an x, y argument, returns the + * scaled coordinate pair in the form [x, y] + */ +export function scale2d(sx, sy) { + return function (x, y) { + return [x * sx, y * sy]; + }; +} + +/** + * Create a composition function which returns a function that combines two + * functions to perform a repeatable transformation + * + * @param {function} f the first function to apply + * @param {function} g the second function to apply + * + * @returns {function} a function which takes an x, y argument, returns the + * transformed coordinate pair in the form [x, y] + */ +export function composeTransform(f, g) { + return function (x, y) { + const fResult = f(x, y); + return g(fResult[0], fResult[1]); + }; +} + +/** + * Return a function which memoizes the last result. If arguments are the same, + * then memoized result returned. + * + * @param {function} f the transformation function to memoize, assumes takes two arguments 'x' and 'y' + * + * @returns {function} a function which takes and x, y argument, and will either return the saved result + * if the arguments are the same on subsequent calls, or compute a new result if they are different. + */ +export function memoizeTransform(f) { + let lastX = undefined; + let lastY = undefined; + let lastResult = undefined; + + return function (x, y) { + if (x === lastX && y === lastY) { + return lastResult; + } + + lastX = x; + lastY = y; + lastResult = f(x, y); + return lastResult; + }; +} diff --git a/test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.spec.js b/test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.spec.js new file mode 100644 index 00000000..6960140e --- /dev/null +++ b/test/fixtures/coordinate-transformation/exemplar/coordinate-transformation.spec.js @@ -0,0 +1,116 @@ +import { + translate2d, + scale2d, + composeTransform, + memoizeTransform, +} from './coordinate-transformation'; + +const fakeTransform = () => { + let first = true; + + return () => { + if (first) { + first = false; + return [1, 1]; + } + + return false; + }; +}; + +describe('translate2d', () => { + test('should return a function', () => { + expect(typeof translate2d(0, 0)).toBe('function'); + }); + + const dx = 3; + const dy = -5; + const translator = translate2d(dx, dy); + const x1 = 0; + const y1 = 0; + const expected = [3, -5]; + test('should be predictable', () => { + expect(translator(x1, y1)).toEqual(expected); + }); + + const x2 = 4; + const y2 = 5; + const reusedExpected = [7, 0]; + test('should be reusable', () => { + expect(translator(x2, y2)).toEqual(reusedExpected); + }); +}); + +describe('scale2d', () => { + test('should return a function', () => { + expect(typeof scale2d(0, 0)).toBe('function'); + }); + + const dx = 4; + const dy = 2; + const scaler = scale2d(dx, dy); + const x1 = 1; + const y1 = 1; + const expected = [4, 2]; + test('should be predictable', () => { + expect(scaler(x1, y1)).toEqual(expected); + }); + + const x2 = -2; + const y2 = 5; + const reusedExpected = [-8, 10]; + test('should be reusable', () => { + expect(scaler(x2, y2)).toEqual(reusedExpected); + }); +}); + +describe('composeTransform', () => { + const dx = -6; + const dy = 10; + const translator = translate2d(dx, dy); + const sx = 3; + const sy = 2; + const scaler = scale2d(sx, sy); + + test('should return a function', () => { + expect(typeof composeTransform(translator, scaler)).toBe('function'); + }); + + test('should compose two translate functions', () => { + const composeTranslate = composeTransform(translator, translator); + expect(composeTranslate(0, 0)).toEqual([-12, 20]); + }); + + test('should compose two scale functions', () => { + const composeScale = composeTransform(scaler, scaler); + expect(composeScale(1, 1)).toEqual([9, 4]); + }); + + test('should compose in the correct order: g(f(x))', () => { + const composed = composeTransform(scaler, translator); + expect(composed(0, 0)).toEqual([-6, 10]); + }); + + test('should compose in the opposite order: g(f(x))', () => { + const composed = composeTransform(translator, scaler); + expect(composed(0, 0)).toEqual([-18, 20]); + }); +}); + +describe('memoizeTransform', () => { + test('should return a function', () => { + expect(typeof memoizeTransform(translate2d(0, 0))).toBe('function'); + }); + + test('should return the same result if given the same input', () => { + const memoizedTranslate = memoizeTransform(translate2d(2, 2)); + expect(memoizedTranslate(2, 2)).toEqual([4, 4]); + expect(memoizedTranslate(2, 2)).toEqual([4, 4]); + }); + + test('should not call the memoized function if the input is the same', () => { + const memoizedTransform = memoizeTransform(fakeTransform()); + expect(memoizedTransform(5, 5)).toEqual([1, 1]); + expect(memoizedTransform(5, 5)).toEqual([1, 1]); + }); +}); diff --git a/test/fixtures/elyses-analytic-enchantments/exemplar/.meta/config.json b/test/fixtures/elyses-analytic-enchantments/exemplar/.meta/config.json new file mode 100644 index 00000000..f6d240c3 --- /dev/null +++ b/test/fixtures/elyses-analytic-enchantments/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "TODO: add blurb for elyses-analytic-enchantments exercise", + "authors": ["peterchu999", "SleeplessByte"], + "contributors": [], + "files": { + "solution": ["enchantments.js"], + "test": ["enchantments.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": [] +} diff --git a/test/fixtures/elyses-analytic-enchantments/exemplar/.meta/exemplar.js b/test/fixtures/elyses-analytic-enchantments/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..40bf1206 --- /dev/null +++ b/test/fixtures/elyses-analytic-enchantments/exemplar/.meta/exemplar.js @@ -0,0 +1,67 @@ +/** + * Get the position (index) of the card in the given stack + * + * @param {number[]} stack + * @param {number} card + * + * @returns {number} position of the card in the stack + */ +export function getCardPosition(stack, card) { + return stack.indexOf(card); +} + +/** + * Determine if the stack contains the card + * + * @param {number[]} stack + * @param {number} card + * + * @returns {boolean} true if card is in the stack, false otherwise + */ +export function doesStackIncludeCard(stack, card) { + return stack.includes(card); +} + +/** + * Determine if each card is even + * + * @param {number[]} stack + * + * @returns {boolean} position of the first card that is even + */ +export function isEachCardEven(stack) { + return stack.every((card) => card % 2 === 0); +} + +/** + * Check if stack contains odd-value card + * + * @param {number[]} stack + * + * @returns {boolean} Whether array contains odd card + */ +export function doesStackIncludeOddCard(stack) { + return stack.some((card) => card % 2 !== 0); +} + +/** + * Get the first odd card from the stack + * + * @param {number[]} stack + * + * @returns {number} the first odd value + */ +export function getFirstOddCard(stack) { + return stack.find((card) => card % 2 !== 0); +} + +/** + * Determine the position of the first card that is even + * + * @param {number[]} stack + * + * @returns {number} position of the first card that is even + */ +export function getFirstEvenCardPosition(stack) { + return stack.findIndex((card) => card % 2 === 0); +} diff --git a/test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.js b/test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.js new file mode 100644 index 00000000..40bf1206 --- /dev/null +++ b/test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.js @@ -0,0 +1,67 @@ +/** + * Get the position (index) of the card in the given stack + * + * @param {number[]} stack + * @param {number} card + * + * @returns {number} position of the card in the stack + */ +export function getCardPosition(stack, card) { + return stack.indexOf(card); +} + +/** + * Determine if the stack contains the card + * + * @param {number[]} stack + * @param {number} card + * + * @returns {boolean} true if card is in the stack, false otherwise + */ +export function doesStackIncludeCard(stack, card) { + return stack.includes(card); +} + +/** + * Determine if each card is even + * + * @param {number[]} stack + * + * @returns {boolean} position of the first card that is even + */ +export function isEachCardEven(stack) { + return stack.every((card) => card % 2 === 0); +} + +/** + * Check if stack contains odd-value card + * + * @param {number[]} stack + * + * @returns {boolean} Whether array contains odd card + */ +export function doesStackIncludeOddCard(stack) { + return stack.some((card) => card % 2 !== 0); +} + +/** + * Get the first odd card from the stack + * + * @param {number[]} stack + * + * @returns {number} the first odd value + */ +export function getFirstOddCard(stack) { + return stack.find((card) => card % 2 !== 0); +} + +/** + * Determine the position of the first card that is even + * + * @param {number[]} stack + * + * @returns {number} position of the first card that is even + */ +export function getFirstEvenCardPosition(stack) { + return stack.findIndex((card) => card % 2 === 0); +} diff --git a/test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.spec.js b/test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.spec.js new file mode 100644 index 00000000..2418c736 --- /dev/null +++ b/test/fixtures/elyses-analytic-enchantments/exemplar/enchantments.spec.js @@ -0,0 +1,111 @@ +// @ts-check + +import { + getCardPosition, + doesStackIncludeCard, + isEachCardEven, + doesStackIncludeOddCard, + getFirstOddCard, + getFirstEvenCardPosition, +} from './enchantments'; + +/** + * @template T the expected return type + * @typedef {Array<[number[], number, T]>} TestSingleMatrix + */ + +/** + * @template T the expected return type + * @typedef {Array<[number[], T]>} TestAllMatrix + **/ + +describe('elyses analytic enchantments', () => { + describe('getCardPosition', () => { + /** @type {TestSingleMatrix} */ + const getCardPositionTestCases = [ + [[1, 2, 3], 1, 0], + [[1, 2, 2], 2, 1], + [[1, 2, 3], 4, -1], + ]; + + getCardPositionTestCases.forEach(([array, item, expected]) => { + test(`getCardIndex([${array}], ${item})`, () => { + expect(getCardPosition(array, item)).toStrictEqual(expected); + }); + }); + }); + + describe('doesStackIncludeCard', () => { + /** @type {TestSingleMatrix} */ + const doesStackIncludeCardTestCases = [ + [[1, 2, 3], 1, true], + [[1, 2, 3], 4, false], + ]; + + doesStackIncludeCardTestCases.forEach(([array, item, expected]) => { + test(`doesStackIncludeCard([${array}],${item})`, () => { + expect(doesStackIncludeCard(array, item)).toBe(expected); + }); + }); + }); + + describe('isEachCardEven', () => { + /** @type {TestAllMatrix} */ + const isEachCardEvenTestCases = [ + [[1], false], + [[2, 5], false], + [[2, 4, 8, 6], true], + ]; + + isEachCardEvenTestCases.forEach(([array, expected]) => { + test(`isEachCardEven([${array}])`, () => { + expect(isEachCardEven(array)).toStrictEqual(expected); + }); + }); + }); + + describe('doesStackIncludeOddCard', () => { + /** @type {TestAllMatrix} */ + const doesStackIncludesOddCardTestCases = [ + [[2, 4, 6], false], + [[2, 5], true], + [[1, 3, 5, 7], true], + ]; + + doesStackIncludesOddCardTestCases.forEach(([array, expected]) => { + test(`doesStackIncludeOddCard([${array}])`, () => { + expect(doesStackIncludeOddCard(array)).toStrictEqual(expected); + }); + }); + }); + + describe('getFirstOddCard', () => { + /** @type {TestAllMatrix} */ + const getFirstOddCardTestCases = [ + [[2, 4, 1, 3], 1], + [[1, 2], 1], + [[4, 2, 6], undefined], + ]; + + getFirstOddCardTestCases.forEach(([array, expected]) => { + test(`getFirstOddCard([${array}])`, () => { + expect(getFirstOddCard(array)).toStrictEqual(expected); + }); + }); + }); + + describe('getFirstEvenCardPosition', () => { + /** @type {TestAllMatrix} */ + const getFirstEvenCardPositionTestCases = [ + [[2, 4, 1, 3], 0], + [[1, 2], 1], + [[1, 3, 5], -1], + ]; + + getFirstEvenCardPositionTestCases.forEach(([array, expected]) => { + test(`getFirstEvenCardPosition([${array}])`, () => { + expect(getFirstEvenCardPosition(array)).toStrictEqual(expected); + }); + }); + }); +}); diff --git a/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/config.json b/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/config.json new file mode 100644 index 00000000..527dff2d --- /dev/null +++ b/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/config.json @@ -0,0 +1,12 @@ +{ + "blurb": "Elyse's magic training continues, teaching you about array destructing and the rest/spread operator.", + "icon": "elyses-enchantments", + "authors": ["kristinaborn"], + "contributors": ["SleeplessByte", "angelikatyborska"], + "files": { + "solution": ["enchantments.js"], + "test": ["enchantments.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": [] +} diff --git a/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/design.md b/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/design.md new file mode 100644 index 00000000..f2c20fea --- /dev/null +++ b/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/design.md @@ -0,0 +1,28 @@ +# Design + +## Learning objectives + +- Using destructuring to get the first item of an array +- Using destructuring to get the second item of an array (skip hole) +- Using destructuring + rest elements to get the last item of an array +- Using destructuring to get the first two items of an array +- Using destructuring + rest elements to get the head and tail of an array +- Using spread to turn an array into a list of parameters +- Using rest elements to turn a list of parameters into an array +- Using destructuring to swap two values + +## Out of scope + +- Anything with objects +- Default values + +## Concepts + +- `array-destructuring` +- `rest-and-spread` + +## Prerequisites + +- `arrays` are needed to understand array restructuring +- `functions` are needed as basis for rest parameters +- `objects` are needed for object spread etc. (will be added to the exercise/concept later) diff --git a/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/exemplar.js b/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..5b10926f --- /dev/null +++ b/test/fixtures/elyses-destructured-enchantments/exemplar/.meta/exemplar.js @@ -0,0 +1,27 @@ +export function getFirstCard(deck) { + const [first] = deck; + + return first; +} + +export function getSecondCard(deck) { + const [, second] = deck; + + return second; +} + +export function swapTopTwoCards([a, b, ...rest]) { + return [b, a, ...rest]; +} + +export function discardTopCard(deck) { + const [first, ...rest] = deck; + + return [first, rest]; +} + +const FACE_CARDS = ['jack', 'queen', 'king']; + +export function insertFaceCards([head, ...tail]) { + return [head, ...FACE_CARDS, ...tail]; +} diff --git a/test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.js b/test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.js new file mode 100644 index 00000000..5b10926f --- /dev/null +++ b/test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.js @@ -0,0 +1,27 @@ +export function getFirstCard(deck) { + const [first] = deck; + + return first; +} + +export function getSecondCard(deck) { + const [, second] = deck; + + return second; +} + +export function swapTopTwoCards([a, b, ...rest]) { + return [b, a, ...rest]; +} + +export function discardTopCard(deck) { + const [first, ...rest] = deck; + + return [first, rest]; +} + +const FACE_CARDS = ['jack', 'queen', 'king']; + +export function insertFaceCards([head, ...tail]) { + return [head, ...FACE_CARDS, ...tail]; +} diff --git a/test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.spec.js b/test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.spec.js new file mode 100644 index 00000000..7f61a11c --- /dev/null +++ b/test/fixtures/elyses-destructured-enchantments/exemplar/enchantments.spec.js @@ -0,0 +1,87 @@ +// @ts-check + +import { + discardTopCard, + getFirstCard, + getSecondCard, + insertFaceCards, + swapTopTwoCards, +} from './enchantments'; + +describe('getFirstCard', () => { + test('from a deck with a single card', () => { + expect(getFirstCard([3])).toBe(3); + }); + + test('from a deck with many cards', () => { + expect(getFirstCard([8, 3, 9, 5])).toBe(8); + }); + + test('from an empty deck', () => { + expect(getFirstCard([])).toBe(undefined); + }); +}); + +describe('getSecondCard', () => { + test('from a deck with two cards', () => { + expect(getSecondCard([10, 4])).toBe(4); + }); + + test('from a deck with many cards', () => { + expect(getSecondCard([2, 5, 1, 6])).toBe(5); + }); + + test('from an empty deck', () => { + expect(getSecondCard([])).toBe(undefined); + }); + + test('from a deck with one card', () => { + expect(getSecondCard([8])).toBe(undefined); + }); +}); + +describe('swapTopTwoCards', () => { + test('in a deck with two cards', () => { + expect(swapTopTwoCards([3, 6])).toStrictEqual([6, 3]); + }); + + test('in a deck with many cards', () => { + expect(swapTopTwoCards([10, 4, 3, 7, 8])).toStrictEqual([4, 10, 3, 7, 8]); + }); +}); + +describe('discardTopCard', () => { + test('from a deck with one card', () => { + expect(discardTopCard([7])).toStrictEqual([7, []]); + }); + + test('from a deck with many cards', () => { + expect(discardTopCard([9, 2, 10, 4])).toStrictEqual([9, [2, 10, 4]]); + }); +}); + +describe('insertFaceCards', () => { + test('into a deck with many cards', () => { + expect(insertFaceCards([3, 10, 7])).toStrictEqual([ + 3, + 'jack', + 'queen', + 'king', + 10, + 7, + ]); + }); + + test('into a deck with one card', () => { + expect(insertFaceCards([9])).toStrictEqual([9, 'jack', 'queen', 'king']); + }); + + test('into a deck with no cards', () => { + expect(insertFaceCards([])).toStrictEqual([ + undefined, + 'jack', + 'queen', + 'king', + ]); + }); +}); diff --git a/test/fixtures/elyses-destructured-enchantments/exemplar/global.d.ts b/test/fixtures/elyses-destructured-enchantments/exemplar/global.d.ts new file mode 100644 index 00000000..db108140 --- /dev/null +++ b/test/fixtures/elyses-destructured-enchantments/exemplar/global.d.ts @@ -0,0 +1,5 @@ +/** + * In various IDEs, such as vscode, this will add type information on the fly + */ + +declare type Card = number | 'jack' | 'queen' | 'king' | undefined; diff --git a/test/fixtures/elyses-enchantments/exemplar/.meta/config.json b/test/fixtures/elyses-enchantments/exemplar/.meta/config.json new file mode 100644 index 00000000..e049072f --- /dev/null +++ b/test/fixtures/elyses-enchantments/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Help Elyse with her Enchantments and learn about arrays in the process.", + "authors": ["ovidiu141", "SleeplessByte"], + "contributors": ["peterchu999"], + "files": { + "solution": ["enchantments.js"], + "test": ["enchantments.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["go/card-tricks"] +} diff --git a/test/fixtures/elyses-enchantments/exemplar/.meta/design.md b/test/fixtures/elyses-enchantments/exemplar/.meta/design.md new file mode 100644 index 00000000..7a49d6f6 --- /dev/null +++ b/test/fixtures/elyses-enchantments/exemplar/.meta/design.md @@ -0,0 +1,21 @@ +# Design + +## Learning objectives + +## Out of scope + +## Concepts + +- `arrays` + +## Prerequisites + +- `numbers` (and `basics`) + +## Analyzer + +This exercise could benefit from the following rules added to the the [analyzer][analyzer]: + +- Verify the simplicity of every method + +[analyzer]: https://github.com/exercism/javascript-analyzer diff --git a/test/fixtures/elyses-enchantments/exemplar/.meta/exemplar.js b/test/fixtures/elyses-enchantments/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..6b80dc6e --- /dev/null +++ b/test/fixtures/elyses-enchantments/exemplar/.meta/exemplar.js @@ -0,0 +1,102 @@ +// @ts-check + +/** + * Retrieve card from cards array at the 0-based position + * + * @param {number[]} cards + * @param {number} position + * + * @returns {number} the card + */ +export function getItem(cards, position) { + return cards[position]; +} + +/** + * Exchange card with replacementCard at the 0-based position + * + * @param {number[]} cards + * @param {number} position + * @param {number} replacementCard + * + * @returns {number[]} the cards with the change applied + */ +export function setItem(cards, position, replacementCard) { + cards[position] = replacementCard; + return cards; +} + +/** + * Insert newCard at the end of the cards array + * + * @param {number[]} cards + * @param {number} newCard + * + * @returns {number[]} the cards with the newCard applied + */ +export function insertItemAtTop(cards, newCard) { + cards.push(newCard); + return cards; +} + +/** + * Remove the card at the 0-based position + * + * @param {number[]} cards + * @param {number} position + * + * @returns {number[]} the cards without the removed card + */ +export function removeItem(cards, position) { + cards.splice(position, 1); + return cards; +} + +/** + * Remove card from the end of the cards array + * + * @param {number[]} cards + * + * @returns {number[]} the cards without the removed card + */ +export function removeItemFromTop(cards) { + cards.pop(); + return cards; +} + +/** + * Insert newCard at beginning of the cards array + * + * @param {number[]} cards + * @param {number} newCard + * + * @returns {number[]} the cards including the new card + */ +export function insertItemAtBottom(cards, newCard) { + cards.unshift(newCard); + return cards; +} + +/** + * Remove card from the beginning of the cards cards + * + * @param {number[]} cards + * + * @returns {number[]} the cards without the removed card + */ +export function removeItemAtBottom(cards) { + cards.shift(); + return cards; +} + +/** + * Compare the number of cards with the given stackSize + * + * @param {number[]} cards + * @param {number} stackSize + * + * @returns {boolean} true if there are exactly stackSize number of cards, false otherwise + */ +export function checkSizeOfStack(cards, stackSize) { + return cards.length === stackSize; +} diff --git a/test/fixtures/elyses-enchantments/exemplar/enchantments.js b/test/fixtures/elyses-enchantments/exemplar/enchantments.js new file mode 100644 index 00000000..6b80dc6e --- /dev/null +++ b/test/fixtures/elyses-enchantments/exemplar/enchantments.js @@ -0,0 +1,102 @@ +// @ts-check + +/** + * Retrieve card from cards array at the 0-based position + * + * @param {number[]} cards + * @param {number} position + * + * @returns {number} the card + */ +export function getItem(cards, position) { + return cards[position]; +} + +/** + * Exchange card with replacementCard at the 0-based position + * + * @param {number[]} cards + * @param {number} position + * @param {number} replacementCard + * + * @returns {number[]} the cards with the change applied + */ +export function setItem(cards, position, replacementCard) { + cards[position] = replacementCard; + return cards; +} + +/** + * Insert newCard at the end of the cards array + * + * @param {number[]} cards + * @param {number} newCard + * + * @returns {number[]} the cards with the newCard applied + */ +export function insertItemAtTop(cards, newCard) { + cards.push(newCard); + return cards; +} + +/** + * Remove the card at the 0-based position + * + * @param {number[]} cards + * @param {number} position + * + * @returns {number[]} the cards without the removed card + */ +export function removeItem(cards, position) { + cards.splice(position, 1); + return cards; +} + +/** + * Remove card from the end of the cards array + * + * @param {number[]} cards + * + * @returns {number[]} the cards without the removed card + */ +export function removeItemFromTop(cards) { + cards.pop(); + return cards; +} + +/** + * Insert newCard at beginning of the cards array + * + * @param {number[]} cards + * @param {number} newCard + * + * @returns {number[]} the cards including the new card + */ +export function insertItemAtBottom(cards, newCard) { + cards.unshift(newCard); + return cards; +} + +/** + * Remove card from the beginning of the cards cards + * + * @param {number[]} cards + * + * @returns {number[]} the cards without the removed card + */ +export function removeItemAtBottom(cards) { + cards.shift(); + return cards; +} + +/** + * Compare the number of cards with the given stackSize + * + * @param {number[]} cards + * @param {number} stackSize + * + * @returns {boolean} true if there are exactly stackSize number of cards, false otherwise + */ +export function checkSizeOfStack(cards, stackSize) { + return cards.length === stackSize; +} diff --git a/test/fixtures/elyses-enchantments/exemplar/enchantments.spec.js b/test/fixtures/elyses-enchantments/exemplar/enchantments.spec.js new file mode 100644 index 00000000..ae66970a --- /dev/null +++ b/test/fixtures/elyses-enchantments/exemplar/enchantments.spec.js @@ -0,0 +1,283 @@ +//@ts-check + +import { + getItem, + setItem, + insertItemAtTop, + insertItemAtBottom, + removeItem, + removeItemFromTop, + removeItemAtBottom, + checkSizeOfStack, +} from './enchantments'; + +describe('Elyses enchantments', () => { + describe('pick a card', () => { + test('get the first card', () => { + const stack = [1, 2, 3]; + const expected = 1; + + expect(getItem(stack, 0)).toBe(expected); + }); + + test('get the middle card', () => { + const stack = [4, 5, 6]; + const expected = 5; + + expect(getItem(stack, 1)).toBe(expected); + }); + + test('get the last card', () => { + const stack = [9, 8, 7]; + const expected = 7; + + expect(getItem(stack, 2)).toBe(expected); + }); + }); + + describe('sleight of hand', () => { + test('replace the first card with a 7', () => { + const stack = [1, 2, 3]; + const position = 0; + const replacement = 7; + + const expected = [7, 2, 3]; + expect(setItem(stack, position, replacement)).toStrictEqual(expected); + }); + + test('replace the middle card with a 5', () => { + const stack = [2, 2, 2]; + const position = 1; + const replacement = 5; + + const expected = [2, 5, 2]; + expect(setItem(stack, position, replacement)).toStrictEqual(expected); + }); + + test('replace the last card with a 7', () => { + const stack = [7, 7, 6]; + const position = 2; + const replacement = 7; + + const expected = [7, 7, 7]; + expect(setItem(stack, position, replacement)).toStrictEqual(expected); + }); + }); + + describe('make cards appear', () => { + test('adding a second card at the top', () => { + const stack = [1]; + const newCard = 5; + + const expected = [1, 5]; + expect(insertItemAtTop(stack, newCard)).toStrictEqual(expected); + }); + + test('adding a third card at the top', () => { + const stack = [1, 5]; + const newCard = 9; + + const expected = [1, 5, 9]; + expect(insertItemAtTop(stack, newCard)).toStrictEqual(expected); + }); + + test('adding a fourth card at the top', () => { + const stack = [1, 5, 9]; + const newCard = 2; + + const expected = [1, 5, 9, 2]; + expect(insertItemAtTop(stack, newCard)).toStrictEqual(expected); + }); + + test('adding a different fourth card at the top', () => { + const stack = [1, 5, 9]; + const newCard = 8; + + const expected = [1, 5, 9, 8]; + expect(insertItemAtTop(stack, newCard)).toStrictEqual(expected); + }); + + test('adding multiple cards to the stack at the top', () => { + const stack = [1]; + + insertItemAtTop(stack, 5); + insertItemAtTop(stack, 9); + + const expected = [1, 5, 9]; + expect(stack).toStrictEqual(expected); + }); + + test('adding a second card to the bottom', () => { + const stack = [1]; + const newCard = 5; + + const expected = [5, 1]; + expect(insertItemAtBottom(stack, newCard)).toStrictEqual(expected); + }); + + test('adding a third card to the bottom', () => { + const stack = [5, 1]; + const newCard = 9; + + const expected = [9, 5, 1]; + expect(insertItemAtBottom(stack, newCard)).toStrictEqual(expected); + }); + + test('adding a fourth card to the bottom', () => { + const stack = [9, 5, 1]; + const newCard = 2; + + const expected = [2, 9, 5, 1]; + expect(insertItemAtBottom(stack, newCard)).toStrictEqual(expected); + }); + + test('adding a different fourth card to the bottom', () => { + const stack = [9, 5, 1]; + const newCard = 8; + + const expected = [8, 9, 5, 1]; + expect(insertItemAtBottom(stack, newCard)).toStrictEqual(expected); + }); + + test('adding multiple cards to the stack to the bottom', () => { + const stack = [1]; + + insertItemAtBottom(stack, 5); + insertItemAtBottom(stack, 9); + + const expected = [9, 5, 1]; + expect(stack).toStrictEqual(expected); + }); + }); + + describe('make cards disappear', () => { + test('remove the card at the bottom', () => { + const stack = [1, 2, 3, 4]; + const position = 0; + + const expected = [2, 3, 4]; + + if (stack[0] === undefined) { + // eslint-disable-next-line no-undef + fail( + new Error( + 'The card has disappeared, but the stack has not changed in size. This magic trick has turned into actual magic. Perhaps a different method of removing the card will result in a stack that Elyse can work with...' + ) + ); + } + + expect(removeItem(stack, position)).toStrictEqual(expected); + }); + + test('remove the card at the top', () => { + const stack = [1, 2, 3, 4]; + const position = 3; + + const expected = [1, 2, 3]; + expect(removeItem(stack, position)).toStrictEqual(expected); + }); + + test('remove the second card', () => { + const stack = [1, 2, 3, 4]; + const position = 1; + + const expected = [1, 3, 4]; + expect(removeItem(stack, position)).toStrictEqual(expected); + }); + + test('remove the middle two cards', () => { + const stack = [1, 2, 3, 4]; + + removeItem(stack, 1); + removeItem(stack, 1); + + const expected = [1, 4]; + expect(expected).toStrictEqual(expected); + }); + + test('remove the only card from the top', () => { + const stack = [1]; + const expected = []; + expect(removeItemFromTop(stack)).toStrictEqual(expected); + }); + + test('remove the card from the top', () => { + const stack = [1, 2, 3]; + const expected = [1, 2]; + expect(removeItemFromTop(stack)).toStrictEqual(expected); + }); + + test('remove two cards from the top', () => { + const stack = [1, 2, 3]; + + removeItemFromTop(stack); + removeItemFromTop(stack); + + const expected = [1]; + expect(expected).toStrictEqual(expected); + }); + + test('remove the only card from the bottom', () => { + const stack = [1]; + const expected = []; + expect(removeItemAtBottom(stack)).toStrictEqual(expected); + }); + + test('remove the card from the bottom', () => { + const stack = [1, 2, 3]; + const expected = [2, 3]; + expect(removeItemAtBottom(stack)).toStrictEqual(expected); + }); + + test('remove two cards from the bottom', () => { + const stack = [1, 2, 3]; + + removeItemFromTop(stack); + removeItemFromTop(stack); + + const expected = [3]; + expect(expected).toStrictEqual(expected); + }); + }); + + describe('check your work', () => { + describe('an empty stack of cards', () => { + test('has 0 cards', () => { + const stack = []; + + expect(checkSizeOfStack(stack, 0)).toBe(true); + expect(checkSizeOfStack(stack, 1)).toBe(false); + }); + }); + + describe('a stack with a single card', () => { + test('has exactly 1 card', () => { + const stack = [7]; + + expect(checkSizeOfStack(stack, 0)).toBe(false); + expect(checkSizeOfStack(stack, 1)).toBe(true); + expect(checkSizeOfStack(stack, 2)).toBe(false); + }); + }); + + describe('a stack with the even cards', () => { + test('has exactly 4 cards', () => { + const stack = [2, 4, 6, 8]; + + expect(checkSizeOfStack(stack, 3)).toBe(false); + expect(checkSizeOfStack(stack, 4)).toBe(true); + expect(checkSizeOfStack(stack, 5)).toBe(false); + }); + }); + + describe('a stack with the odd cards', () => { + test('has exactly 5 cards', () => { + const stack = [1, 3, 5, 7, 9]; + + expect(checkSizeOfStack(stack, 3)).toBe(false); + expect(checkSizeOfStack(stack, 4)).toBe(false); + expect(checkSizeOfStack(stack, 5)).toBe(true); + }); + }); + }); +}); diff --git a/test/fixtures/elyses-looping-enchantments/exemplar/.meta/config.json b/test/fixtures/elyses-looping-enchantments/exemplar/.meta/config.json new file mode 100644 index 00000000..13fe98a4 --- /dev/null +++ b/test/fixtures/elyses-looping-enchantments/exemplar/.meta/config.json @@ -0,0 +1,12 @@ +{ + "blurb": "Sift through Elyse's array of cards using various looping methods.", + "icon": "elyses-enchantments", + "authors": ["rishiosaur", "SleeplessByte"], + "contributors": ["junedev"], + "files": { + "solution": ["enchantments.js"], + "test": ["enchantments.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": [] +} diff --git a/test/fixtures/elyses-looping-enchantments/exemplar/.meta/design.md b/test/fixtures/elyses-looping-enchantments/exemplar/.meta/design.md new file mode 100644 index 00000000..c0774a88 --- /dev/null +++ b/test/fixtures/elyses-looping-enchantments/exemplar/.meta/design.md @@ -0,0 +1,37 @@ +# Design + +## Learning objectives + +- How to use a `for...of` loop +- How to use `forEach` +- Drawbacks of `forEach` + +## Out of Scope + +The following topics are out of scope because they will be covered by other concept exercise. + +- Methods for array transformation or other analysis then just going through the array +- Sets +- [Iterators][mdn-iterators] in general + +## Concepts + +The Concept this exercise unlocks is: + +- `array-loops` + +## Prerequisites + +- `arrays` because they are the basis data type here +- `for-loops` because they teach the basics of iteration +- `callbacks` and `arrow-functions` because they are needed for `forEach` +- `conditionals` because they are needed in the exercise + +## Analyzer + +This exercise could benefit from the following rules in the [analyzer][analyzer]: + +The analyzer should check that the way to iterate that is mentioned in the instructions was actually used (`essential` feedback). + +[analyzer]: https://github.com/exercism/javascript-analyzer +[mdn-iterators]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol diff --git a/test/fixtures/elyses-looping-enchantments/exemplar/.meta/exemplar.js b/test/fixtures/elyses-looping-enchantments/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..4683b2f1 --- /dev/null +++ b/test/fixtures/elyses-looping-enchantments/exemplar/.meta/exemplar.js @@ -0,0 +1,41 @@ +// @ts-check + +/** + * Determine how many cards of a certain type there are in the deck + * + * @param {number[]} stack + * @param {number} card + * + * @returns {number} number of cards of a single type there are in the deck + */ +export function cardTypeCheck(stack, card) { + let count = 0; + + stack.forEach((c) => { + if (c === card) { + count++; + } + }); + + return count; +} + +/** + * Determine how many cards are odd or even + * + * @param {number[]} stack + * @param {boolean} type the type of value to check for - odd or even + * @returns {number} number of cards that are either odd or even (depending on `type`) + */ +export function determineOddEvenCards(stack, type) { + const moduloResult = type ? 0 : 1; + let count = 0; + + for (const card of stack) { + if (card % 2 === moduloResult) { + count++; + } + } + + return count; +} diff --git a/test/fixtures/elyses-looping-enchantments/exemplar/enchantments.js b/test/fixtures/elyses-looping-enchantments/exemplar/enchantments.js new file mode 100644 index 00000000..4683b2f1 --- /dev/null +++ b/test/fixtures/elyses-looping-enchantments/exemplar/enchantments.js @@ -0,0 +1,41 @@ +// @ts-check + +/** + * Determine how many cards of a certain type there are in the deck + * + * @param {number[]} stack + * @param {number} card + * + * @returns {number} number of cards of a single type there are in the deck + */ +export function cardTypeCheck(stack, card) { + let count = 0; + + stack.forEach((c) => { + if (c === card) { + count++; + } + }); + + return count; +} + +/** + * Determine how many cards are odd or even + * + * @param {number[]} stack + * @param {boolean} type the type of value to check for - odd or even + * @returns {number} number of cards that are either odd or even (depending on `type`) + */ +export function determineOddEvenCards(stack, type) { + const moduloResult = type ? 0 : 1; + let count = 0; + + for (const card of stack) { + if (card % 2 === moduloResult) { + count++; + } + } + + return count; +} diff --git a/test/fixtures/elyses-looping-enchantments/exemplar/enchantments.spec.js b/test/fixtures/elyses-looping-enchantments/exemplar/enchantments.spec.js new file mode 100644 index 00000000..ff829883 --- /dev/null +++ b/test/fixtures/elyses-looping-enchantments/exemplar/enchantments.spec.js @@ -0,0 +1,57 @@ +import { cardTypeCheck, determineOddEvenCards } from './enchantments'; + +const TYPE_IS_ODD = false; +const TYPE_IS_EVEN = true; + +describe('cardTypeCheck', () => { + test('a single matching card', () => { + expect(cardTypeCheck([1], 1)).toBe(1); + }); + + test('a single matching card among many', () => { + expect(cardTypeCheck([7, 4, 7, 3, 1, 2], 1)).toBe(1); + }); + + test('a single unmatched card', () => { + expect(cardTypeCheck([1], 2)).toBe(0); + }); + + test('multiple matching cards', () => { + expect(cardTypeCheck([7, 7, 7], 7)).toBe(3); + }); + + test('multiple matching cards among many', () => { + expect(cardTypeCheck([1, 2, 3, 7, 7, 7, 3, 2, 1], 7)).toBe(3); + }); + + test('no matching cards', () => { + expect(cardTypeCheck([1, 2, 3, 4, 5, 4, 3, 2, 1], 7)).toBe(0); + }); +}); + +describe('determineOddEvenCards', () => { + test('a single odd card', () => { + expect(determineOddEvenCards([1], TYPE_IS_ODD)).toBe(1); + expect(determineOddEvenCards([1], TYPE_IS_EVEN)).toBe(0); + }); + + test('a single even card', () => { + expect(determineOddEvenCards([2], TYPE_IS_ODD)).toBe(0); + expect(determineOddEvenCards([2], TYPE_IS_EVEN)).toBe(1); + }); + + test('multiple odd cards', () => { + expect(determineOddEvenCards([1, 3, 5], TYPE_IS_ODD)).toBe(3); + expect(determineOddEvenCards([1, 3, 5], TYPE_IS_EVEN)).toBe(0); + }); + + test('multiple even cards', () => { + expect(determineOddEvenCards([2, 2, 4, 6, 6], TYPE_IS_ODD)).toBe(0); + expect(determineOddEvenCards([2, 2, 4, 6, 6], TYPE_IS_EVEN)).toBe(5); + }); + + test('a mix of odd and even cards', () => { + expect(determineOddEvenCards([1, 2, 1, 1, 2, 1, 9], TYPE_IS_ODD)).toBe(5); + expect(determineOddEvenCards([1, 2, 1, 1, 2, 1, 9], TYPE_IS_EVEN)).toBe(2); + }); +}); diff --git a/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/config.json b/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/config.json new file mode 100644 index 00000000..a26383c3 --- /dev/null +++ b/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "TODO: add blurb for elyses-transformative-enchantments exercise", + "authors": ["yyyc514"], + "contributors": ["SleeplessByte"], + "files": { + "solution": ["enchantments.js"], + "test": ["enchantments.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": [] +} diff --git a/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/design.md b/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/design.md new file mode 100644 index 00000000..85e462d8 --- /dev/null +++ b/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/design.md @@ -0,0 +1,32 @@ +# Design + +## Learning objectives + +- `.map` +- `.reduce` +- `.slice` +- `.splice` +- `.filter` +- `.sort` +- `.fill` +- `.flatMap` (maybe) + +## Out of scope + +- anything that explicitly loops an array (array-loops) +- anything that returns a boolean (array-analysis) +- anything that implicitly loops (arrays) + - `indexOf` + - `find` + - `findIndex` + - `includes` + - etc. + +## Concepts + +- `array-transformations` + +## Prerequisites + +- `arrays` +- `conditionals` diff --git a/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/exemplar.js b/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..74d817e8 --- /dev/null +++ b/test/fixtures/elyses-transformative-enchantments/exemplar/.meta/exemplar.js @@ -0,0 +1,92 @@ +/** + * Double every card in the deck + * + * @param {number[]} deck + * + * @returns {number[]} deck with every card doubled + */ +export function seeingDouble(deck) { + return deck.map((card) => card * 2); +} + +/** + * Creates triplicates of every 3 found in the deck + * + * @param {number[]} deck + * + * @returns {number[]} deck with triplicate 3s + */ +export function threeOfEachThree(deck) { + return deck.reduce((newDeck, card) => { + if (card === 3) { + newDeck.push(3, 3, 3); + } else { + newDeck.push(card); + } + return newDeck; + }, []); +} + +/** + * Removes every card from the deck but the middle two + * Assumes a deck is always 10 cards + * + * @param {number[]} deck of 10 cards + * + * @returns {number[]} deck with only two middle cards + */ +export function middleTwo(deck) { + // TODO: which implementation? + // const middle = Math.floor(deck.length / 2) + // return deck.slice(middle, middle + 1) + return deck.slice(4, 6); +} + +/** + * Moves the outside two cards to the middle + * + * @param {number[]} deck with 10 cards + * + * @returns {number[]} transformed deck + */ + +export function sandwichTrick(deck) { + const firstCard = deck.shift(); + const lastCard = deck.pop(); + const mid = deck.length / 2; + deck.splice(mid, 0, lastCard, firstCard); + return deck; +} + +/** + * Removes every card from the deck except 2s + * + * @param {number[]} deck + * + * @returns {number[]} deck with only 2s + */ +export function twoIsSpecial(deck) { + return deck.filter((card) => card === 2); +} + +/** + * Returns a perfectly order deck from lowest to highest + * + * @param {number[]} shuffled deck + * + * @returns {number[]} ordered deck + */ +export function perfectlyOrdered(deck) { + return deck.sort((a, b) => a - b); +} + +/** + * Returns a deck with every card equal to the total number of cards + * + * @param {number[]} deck + * + * @returns {number[]} deck + */ +export function countingCards(deck) { + return deck.fill(deck.length); +} diff --git a/test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.js b/test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.js new file mode 100644 index 00000000..74d817e8 --- /dev/null +++ b/test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.js @@ -0,0 +1,92 @@ +/** + * Double every card in the deck + * + * @param {number[]} deck + * + * @returns {number[]} deck with every card doubled + */ +export function seeingDouble(deck) { + return deck.map((card) => card * 2); +} + +/** + * Creates triplicates of every 3 found in the deck + * + * @param {number[]} deck + * + * @returns {number[]} deck with triplicate 3s + */ +export function threeOfEachThree(deck) { + return deck.reduce((newDeck, card) => { + if (card === 3) { + newDeck.push(3, 3, 3); + } else { + newDeck.push(card); + } + return newDeck; + }, []); +} + +/** + * Removes every card from the deck but the middle two + * Assumes a deck is always 10 cards + * + * @param {number[]} deck of 10 cards + * + * @returns {number[]} deck with only two middle cards + */ +export function middleTwo(deck) { + // TODO: which implementation? + // const middle = Math.floor(deck.length / 2) + // return deck.slice(middle, middle + 1) + return deck.slice(4, 6); +} + +/** + * Moves the outside two cards to the middle + * + * @param {number[]} deck with 10 cards + * + * @returns {number[]} transformed deck + */ + +export function sandwichTrick(deck) { + const firstCard = deck.shift(); + const lastCard = deck.pop(); + const mid = deck.length / 2; + deck.splice(mid, 0, lastCard, firstCard); + return deck; +} + +/** + * Removes every card from the deck except 2s + * + * @param {number[]} deck + * + * @returns {number[]} deck with only 2s + */ +export function twoIsSpecial(deck) { + return deck.filter((card) => card === 2); +} + +/** + * Returns a perfectly order deck from lowest to highest + * + * @param {number[]} shuffled deck + * + * @returns {number[]} ordered deck + */ +export function perfectlyOrdered(deck) { + return deck.sort((a, b) => a - b); +} + +/** + * Returns a deck with every card equal to the total number of cards + * + * @param {number[]} deck + * + * @returns {number[]} deck + */ +export function countingCards(deck) { + return deck.fill(deck.length); +} diff --git a/test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.spec.js b/test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.spec.js new file mode 100644 index 00000000..c4df447d --- /dev/null +++ b/test/fixtures/elyses-transformative-enchantments/exemplar/enchantments.spec.js @@ -0,0 +1,175 @@ +// @ts-check + +/** + * @template T + * @template Expected + * @typedef {[T, Expected]} TestCase + */ + +/** + * @template T + * @template Expected + * @typedef {TestCase[]} TestCases + */ + +import { + seeingDouble, + threeOfEachThree, + middleTwo, + sandwichTrick, + twoIsSpecial, + perfectlyOrdered, + countingCards, +} from './enchantments'; + +describe('array', () => { + describe('seeingDouble', () => { + /** @type {TestCases} */ + const seeingDoubleTestCases = [ + [[], []], + [[3], [6]], + [ + [1, 2, 3, 4], + [2, 4, 6, 8], + ], + [ + [2, 5, 1, 9], + [4, 10, 2, 18], + ], + ]; + + seeingDoubleTestCases.forEach(([deck, expected]) => { + test(`seeingDouble([${deck}])`, () => { + expect(seeingDouble(deck)).toStrictEqual(expected); + }); + }); + }); + + describe('threeOfEachThree', () => { + /** @type {TestCases} */ + const threeOfEachThreeTestCases = [ + [[], []], + [[3], [3, 3, 3]], + [[2], [2]], + [ + [1, 3, 9, 3, 7], + [1, 3, 3, 3, 9, 3, 3, 3, 7], + ], + ]; + + threeOfEachThreeTestCases.forEach(([deck, expected]) => { + test(`threeOfEachThree([${deck}])`, () => { + expect(threeOfEachThree(deck)).toStrictEqual(expected); + }); + }); + }); + + describe('middleTwo', () => { + /** @type {TestCases} */ + const middleTwoTestCases = [ + [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + [5, 6], + ], + [ + [12, 2, 36, 45, 15, 96, 27, 86, 1, 29], + [15, 96], + ], + ]; + + middleTwoTestCases.forEach(([deck, expected]) => { + test(`middleTwo([${deck}])`, () => { + expect(middleTwo(deck)).toStrictEqual(expected); + }); + }); + }); + + describe('sandwichTrick', () => { + /** @type {TestCases} */ + const sandwichTrickTestCases = [ + [ + [1, 2, 3, 5, 6, 10], + [2, 3, 10, 1, 5, 6], + ], + [ + [12, 3, 5, 87], + [3, 87, 12, 5], + ], + ]; + + sandwichTrickTestCases.forEach(([deck, expected]) => { + test(`sandwichTrick([${deck}])`, () => { + expect(sandwichTrick(deck)).toStrictEqual(expected); + }); + }); + }); + + describe('twoIsSpecial', () => { + /** @type {TestCases} */ + const twoIsSpecialTestCases = [ + [[], []], + [[1, 9, 1], []], + [[1, 2, 9, 1], [2]], + [ + [121, 22, 9, 12, 2, 2, 2, 23, 2], + [2, 2, 2, 2], + ], + ]; + + twoIsSpecialTestCases.forEach(([deck, expected]) => { + test(`twoIsSpecial([${deck}])`, () => { + expect(twoIsSpecial(deck)).toStrictEqual(expected); + }); + }); + }); + + describe('perfectlyOrdered', () => { + /** @type {TestCases} */ + const perfectlyOrderedTestCases = [ + [[], []], + [ + [1, 9, 1], + [1, 1, 9], + ], + [ + [1, 2, 9, 1], + [1, 1, 2, 9], + ], + [ + [10, 1, 5, 3, 2, 8, 7], + [1, 2, 3, 5, 7, 8, 10], + ], + ]; + + perfectlyOrderedTestCases.forEach(([deck, expected]) => { + test(`perfectlyOrdered([${deck}])`, () => { + expect(perfectlyOrdered(deck)).toStrictEqual(expected); + }); + }); + }); + + describe('countingCards', () => { + /** @type {TestCases} */ + const countingCardsTestCases = [ + [[], []], + [ + [1, 9, 1], + [3, 3, 3], + ], + [ + [1, 2, 9, 1], + [4, 4, 4, 4], + ], + [ + [121, 22, 9, 12, 2, 2, 23], + [7, 7, 7, 7, 7, 7, 7], + ], + ]; + + countingCardsTestCases.forEach(([deck, expected]) => { + test(`countingCards([${deck}])`, () => { + expect(countingCards(deck)).toStrictEqual(expected); + }); + }); + }); +}); diff --git a/test/fixtures/factory-sensors/exemplar/.meta/config.json b/test/fixtures/factory-sensors/exemplar/.meta/config.json new file mode 100644 index 00000000..654457af --- /dev/null +++ b/test/fixtures/factory-sensors/exemplar/.meta/config.json @@ -0,0 +1,10 @@ +{ + "blurb": "Learn how to handle errors by creating a piece of software for a newspaper factory.", + "authors": ["TomPradat"], + "contributors": ["SleeplessByte", "junedev"], + "files": { + "solution": ["factory-sensors.js"], + "test": ["factory-sensors.spec.js"], + "exemplar": [".meta/exemplar.js"] + } +} diff --git a/test/fixtures/factory-sensors/exemplar/.meta/design.md b/test/fixtures/factory-sensors/exemplar/.meta/design.md new file mode 100644 index 00000000..3558ef35 --- /dev/null +++ b/test/fixtures/factory-sensors/exemplar/.meta/design.md @@ -0,0 +1,30 @@ +# Design + +## Goal + +The goal of this exercise is to teach the student how to handle errors / exception, throw them and create their own. + +## Learning objectives + +- throw Error +- try {} catch {} +- instanceOf SpecialError +- Creating custom errors + - Prefilling a message + - Constructing a message based on some constructor value + - Storing constructor values on the error (so that you have context) + +## Out of scope + +- Promise rejection +- Throwing non-errors + +## Concepts + +- `errors` + +## Prerequisites + +- `classes` to understand how to create errors +- `conditionals` because they are needed for the exercise +- `null-undefined` because they are needed for the exercise diff --git a/test/fixtures/factory-sensors/exemplar/.meta/exemplar.js b/test/fixtures/factory-sensors/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..cd8daee8 --- /dev/null +++ b/test/fixtures/factory-sensors/exemplar/.meta/exemplar.js @@ -0,0 +1,68 @@ +export class ArgumentError extends Error {} + +export class OverheatingError extends Error { + constructor(temperature) { + super(`The temperature is ${temperature} ! Overheating !`); + this.temperature = temperature; + } +} + +/** + * Check if the humidity level is not too high. + * + * @param {number} humidityPercentage + * @throws {Error} + */ +export function checkHumidityLevel(humidityPercentage) { + if (humidityPercentage > 70) { + throw new Error('Humidity level is too low'); + } +} + +/** + * Check if the temperature is not too high. + * + * @param {number|null} temperature + * @throws {ArgumentError|OverheatingError} + */ +export function reportOverheating(temperature) { + if (temperature === null) { + throw new ArgumentError(); + } else if (temperature > 500) { + throw new OverheatingError(temperature); + } +} + +/** + * Triggers the needed action depending on the result of the machine check. + * + * @param {{ + * check: function, + * alertDeadSensor: function, + * alertOverheating: function, + * shutdown: function + * }} actions + * @throws {ArgumentError|OverheatingError|Error} + */ +export function monitorTheMachine({ + check, + alertDeadSensor, + alertOverheating, + shutdown, +}) { + try { + check(); + } catch (error) { + if (error instanceof ArgumentError) { + alertDeadSensor(); + } else if (error instanceof OverheatingError) { + if (error.temperature > 600) { + shutdown(); + } else { + alertOverheating(); + } + } else { + throw error; + } + } +} diff --git a/test/fixtures/factory-sensors/exemplar/factory-sensors.js b/test/fixtures/factory-sensors/exemplar/factory-sensors.js new file mode 100644 index 00000000..cd8daee8 --- /dev/null +++ b/test/fixtures/factory-sensors/exemplar/factory-sensors.js @@ -0,0 +1,68 @@ +export class ArgumentError extends Error {} + +export class OverheatingError extends Error { + constructor(temperature) { + super(`The temperature is ${temperature} ! Overheating !`); + this.temperature = temperature; + } +} + +/** + * Check if the humidity level is not too high. + * + * @param {number} humidityPercentage + * @throws {Error} + */ +export function checkHumidityLevel(humidityPercentage) { + if (humidityPercentage > 70) { + throw new Error('Humidity level is too low'); + } +} + +/** + * Check if the temperature is not too high. + * + * @param {number|null} temperature + * @throws {ArgumentError|OverheatingError} + */ +export function reportOverheating(temperature) { + if (temperature === null) { + throw new ArgumentError(); + } else if (temperature > 500) { + throw new OverheatingError(temperature); + } +} + +/** + * Triggers the needed action depending on the result of the machine check. + * + * @param {{ + * check: function, + * alertDeadSensor: function, + * alertOverheating: function, + * shutdown: function + * }} actions + * @throws {ArgumentError|OverheatingError|Error} + */ +export function monitorTheMachine({ + check, + alertDeadSensor, + alertOverheating, + shutdown, +}) { + try { + check(); + } catch (error) { + if (error instanceof ArgumentError) { + alertDeadSensor(); + } else if (error instanceof OverheatingError) { + if (error.temperature > 600) { + shutdown(); + } else { + alertOverheating(); + } + } else { + throw error; + } + } +} diff --git a/test/fixtures/factory-sensors/exemplar/factory-sensors.spec.js b/test/fixtures/factory-sensors/exemplar/factory-sensors.spec.js new file mode 100644 index 00000000..3eda0876 --- /dev/null +++ b/test/fixtures/factory-sensors/exemplar/factory-sensors.spec.js @@ -0,0 +1,115 @@ +import { + checkHumidityLevel, + reportOverheating, + monitorTheMachine, + ArgumentError, + OverheatingError, +} from './factory-sensors'; + +describe('checkHumidityLevel', () => { + test('should throw if the humidity percentage is 100', () => { + expect(() => checkHumidityLevel(100)).toThrow(); + }); + + test('should not throw if the humidity level is 53', () => { + expect(() => checkHumidityLevel(53)).not.toThrow(); + }); +}); + +describe('reportOverheating', () => { + test('should not throw if the temperature is 200°C', () => { + expect(() => reportOverheating(200)).not.toThrow(); + }); + + test('should throw an ArgumentError if the temperature is null', () => { + expect(() => reportOverheating(null)).toThrow(ArgumentError); + }); + + test('should throw an OverheatingError if the temperature is 501°C', () => { + expect(() => reportOverheating(501)).toThrow(OverheatingError); + + const getOverheatingErrorTemperature = () => { + try { + reportOverheating(501); + } catch (error) { + return error.temperature; + } + }; + + expect(getOverheatingErrorTemperature()).toBe(501); + }); +}); + +describe('monitorTheMachine', () => { + const actions = { + check: jest.fn(), + alertDeadSensor: jest.fn(), + alertOverheating: jest.fn(), + shutdown: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should call the check method once', () => { + monitorTheMachine(actions); + + expect(actions.check).toHaveBeenCalledTimes(1); + }); + + test("should not call any action if the check doesn't throw", () => { + monitorTheMachine(actions); + + expect(actions.alertDeadSensor).not.toHaveBeenCalled(); + expect(actions.alertOverheating).not.toHaveBeenCalled(); + expect(actions.shutdown).not.toHaveBeenCalled(); + }); + + test('should call only the alertDeadSensor if the check throws an ArgumentError', () => { + actions.check = jest.fn(() => { + throw new ArgumentError(); + }); + monitorTheMachine(actions); + + expect(actions.alertDeadSensor).toHaveBeenCalledTimes(1); + expect(actions.alertOverheating).not.toHaveBeenCalled(); + expect(actions.shutdown).not.toHaveBeenCalled(); + }); + + test('should call only the shutdown action if the check throws an OverheatingError with a temperature equals to 651°C', () => { + actions.check = jest.fn(() => { + throw new OverheatingError(651); + }); + monitorTheMachine(actions); + + expect(actions.alertDeadSensor).not.toHaveBeenCalled(); + expect(actions.alertOverheating).not.toHaveBeenCalled(); + expect(actions.shutdown).toHaveBeenCalledTimes(1); + }); + + test('should call only the alertOverheating if the check throws an OverheatingError with a temperature of 530°C', () => { + actions.check = jest.fn(() => { + throw new OverheatingError(530); + }); + monitorTheMachine(actions); + + expect(actions.alertDeadSensor).not.toHaveBeenCalled(); + expect(actions.alertOverheating).toHaveBeenCalledTimes(1); + expect(actions.shutdown).not.toHaveBeenCalled(); + }); + + test('should rethrow the error if the check throws an unknown error', () => { + class UnknownError extends Error {} + + actions.check = jest.fn(() => { + throw new UnknownError(); + }); + + expect(() => monitorTheMachine(actions)).toThrow(UnknownError); + + expect(actions.alertDeadSensor).not.toHaveBeenCalled(); + expect(actions.alertOverheating).not.toHaveBeenCalled(); + expect(actions.shutdown).not.toHaveBeenCalled(); + }); +}); diff --git a/test/fixtures/freelancer-rates/exemplar/freelancer-rates.js b/test/fixtures/freelancer-rates/exemplar/freelancer-rates.js index 0c0caf26..da707c59 100644 --- a/test/fixtures/freelancer-rates/exemplar/freelancer-rates.js +++ b/test/fixtures/freelancer-rates/exemplar/freelancer-rates.js @@ -25,7 +25,7 @@ * @param {number} ratePerHour * @returns {number} the rate per day */ - export function dayRate(ratePerHour) { +export function dayRate(ratePerHour) { return ratePerHour * 8; } diff --git a/test/fixtures/fruit-picker/exemplar/.meta/config.json b/test/fixtures/fruit-picker/exemplar/.meta/config.json new file mode 100644 index 00000000..f160bcf6 --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/.meta/config.json @@ -0,0 +1,10 @@ +{ + "blurb": "TODO: add blurb for fruit-picker exercise", + "authors": ["neenjaw"], + "contributors": ["SleeplessByte"], + "files": { + "solution": ["fruit-picker.js", "grocer.js"], + "test": ["fruit-picker.spec.js"], + "exemplar": [".meta/exemplar.js"] + } +} diff --git a/test/fixtures/fruit-picker/exemplar/.meta/design.md b/test/fixtures/fruit-picker/exemplar/.meta/design.md new file mode 100644 index 00000000..9c16658f --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/.meta/design.md @@ -0,0 +1,27 @@ +# Design + +## Goal + +The goal of this exercise is to teach the student how _callbacks_ are implemented in `JavaScript`. + +In other words: how _function_ can be passed as an argument to another function, then have them be called in order to control the flow of logic. + +## Learning objectives + +- Function that can pass along a callback function as an argument +- How to write a function that can be used as a callback +- How to compose functions with callbacks + +## Out of scope + +- Promises +- Asynchronicity + +## Concepts + +- `callbacks` + +## Prerequisites + +- `functions` because they are the base for understanding arrow functions +- `objects` because they are needed for the exercise diff --git a/test/fixtures/fruit-picker/exemplar/.meta/env.d.ts b/test/fixtures/fruit-picker/exemplar/.meta/env.d.ts new file mode 100644 index 00000000..4630316d --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/.meta/env.d.ts @@ -0,0 +1,4 @@ +/// + +// This file purely exist so that exemplar.js may live in this folder, without +// that causing syntax errors. diff --git a/test/fixtures/fruit-picker/exemplar/.meta/exemplar.js b/test/fixtures/fruit-picker/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..bbd9aeb9 --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/.meta/exemplar.js @@ -0,0 +1,49 @@ +// @ts-check + +import { checkStatus, checkInventory } from '../grocer'; + +/** + * Returns the service status as a boolean value + * @return {boolean} + */ +export function isServiceOnline() { + return checkStatus((serviceStatus) => serviceStatus === 'ONLINE'); +} + +/** + * Pick a fruit using the checkInventory API + * + * @param {string} variety + * @param {number} quantity + * @param {InventoryCallback} callback + * @return {AvailabilityAction} the result from checkInventory + */ +export function pickFruit(variety, quantity, callback) { + return checkInventory({ variety, quantity }, callback); +} + +/** + * This is a callback function to be passed to the checkInventory API + * handles the next step once the inventory is known + * @param {string | null} err + * @param {boolean} isAvailable + * @return {AvailabilityAction} whether the fruit was purchased 'PURCHASE' or 'NOOP' + */ +export function purchaseInventoryIfAvailable(err, isAvailable) { + if (err) { + throw new Error(err); + } + + return isAvailable ? 'PURCHASE' : 'NOOP'; +} + +/** + * Pick a fruit, and if it is available, purchase it + * + * @param {string} variety + * @param {number} quantity + * @return {AvailabilityAction} whether the fruit was purchased 'PURCHASE' or 'NOOP' + */ +export function pickAndPurchaseFruit(variety, quantity) { + return pickFruit(variety, quantity, purchaseInventoryIfAvailable); +} diff --git a/test/fixtures/fruit-picker/exemplar/fruit-picker.js b/test/fixtures/fruit-picker/exemplar/fruit-picker.js new file mode 100644 index 00000000..2743cc4b --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/fruit-picker.js @@ -0,0 +1,50 @@ +/// +// @ts-check + +import { checkStatus, checkInventory } from './grocer'; + +/** + * Returns the service status as a boolean value + * @return {boolean} + */ +export function isServiceOnline() { + return checkStatus((serviceStatus) => serviceStatus === 'ONLINE'); +} + +/** + * Pick a fruit using the checkInventory API + * + * @param {string} variety + * @param {number} quantity + * @param {InventoryCallback} callback + * @return {AvailabilityAction} the result from checkInventory + */ +export function pickFruit(variety, quantity, callback) { + return checkInventory({ variety, quantity }, callback); +} + +/** + * This is a callback function to be passed to the checkInventory API + * handles the next step once the inventory is known + * @param {string | null} err + * @param {boolean} isAvailable + * @return {AvailabilityAction} whether the fruit was purchased 'PURCHASE' or 'NOOP' + */ +export function purchaseInventoryIfAvailable(err, isAvailable) { + if (err) { + throw new Error(err); + } + + return isAvailable ? 'PURCHASE' : 'NOOP'; +} + +/** + * Pick a fruit, and if it is available, purchase it + * + * @param {string} variety + * @param {number} quantity + * @return {AvailabilityAction} whether the fruit was purchased 'PURCHASE' or 'NOOP' + */ +export function pickAndPurchaseFruit(variety, quantity) { + return pickFruit(variety, quantity, purchaseInventoryIfAvailable); +} diff --git a/test/fixtures/fruit-picker/exemplar/fruit-picker.spec.js b/test/fixtures/fruit-picker/exemplar/fruit-picker.spec.js new file mode 100644 index 00000000..66a918bb --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/fruit-picker.spec.js @@ -0,0 +1,122 @@ +// @ts-check + +import { + purchaseInventoryIfAvailable, + isServiceOnline, + pickAndPurchaseFruit, + pickFruit, +} from './fruit-picker'; +import { + setStatus, + resetStatus, + setResponse, + getLastQuery, + resetQuery, +} from './grocer'; + +describe('service status', () => { + beforeEach(() => { + resetStatus(); + }); + + test('returns the initial status of the service', () => { + expect(isServiceOnline()).toBe(false); + }); + + test('returns the status when service is online', () => { + setStatus('ONLINE'); + expect(isServiceOnline()).toBe(true); + }); +}); + +describe('inventory service', () => { + beforeEach(() => { + resetQuery(); + }); + + test('uses the query format', () => { + pickFruit('strawberry', 5, () => {}); + expect(getLastQuery()).toEqual({ + variety: 'strawberry', + quantity: 5, + }); + }); + + test('takes parameters for the query', () => { + pickFruit('blueberry', 20, () => {}); + expect(getLastQuery()).toEqual({ + variety: 'blueberry', + quantity: 20, + }); + }); + + test('returns synchronously', () => { + setResponse(true); + expect(pickFruit('melon', 1, (res) => res)).toBe(true); + }); + + test('returns the inventory status', () => { + setResponse(null, false); + expect(pickFruit('melon', 1, (err, isAvailable) => isAvailable)).toBe( + false + ); + }); +}); + +describe('inventory result callback', () => { + test('throws error if receives inventory error', () => { + expect(() => { + purchaseInventoryIfAvailable('inventory error'); + }).toThrow(); + }); + + test('returns "PURCHASE" when inventory is available', () => { + expect(purchaseInventoryIfAvailable(null, { quantityAvailable: 4 })).toBe( + 'PURCHASE' + ); + }); + + test('returns "NOOP" when inventory is unavailable', () => { + expect(purchaseInventoryIfAvailable(null, false)).toBe('NOOP'); + }); +}); + +describe('putting it together', () => { + beforeEach(() => { + resetQuery(); + }); + + test('uses the query format', () => { + setResponse(null, true); + pickAndPurchaseFruit('jackfruit', 15); + expect(getLastQuery()).toEqual({ + variety: 'jackfruit', + quantity: 15, + }); + }); + + test('takes parameters for the query', () => { + setResponse(null, true); + pickAndPurchaseFruit('raspberry', 30); + expect(getLastQuery()).toEqual({ + variety: 'raspberry', + quantity: 30, + }); + }); + + test('throws error if receives inventory error', () => { + expect(() => { + pickAndPurchaseFruit('honeydew', 3); + }).toThrow(); + }); + + test('returns "NOOP" if quantity not available', () => { + setResponse(null, false); + expect(pickAndPurchaseFruit('apples', 12)).toBe('NOOP'); + }); + + test('returns "PURCHASE" if quantity available', () => { + setResponse(null, { quantityAvailable: 23 }); + expect(pickAndPurchaseFruit('oranges', 22)).toBe('PURCHASE'); + }); +}); diff --git a/test/fixtures/fruit-picker/exemplar/global.d.ts b/test/fixtures/fruit-picker/exemplar/global.d.ts new file mode 100644 index 00000000..26535a64 --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/global.d.ts @@ -0,0 +1,29 @@ +/** + * These are the shapes of the external grocer service', the return values and the + * functions. Don't change these. In various IDEs, such as vscode, this will add + * type information on the fly + */ + +type Status = 'OFFLINE' | 'ONLINE'; +type AvailabilityAction = 'NOOP' | 'PURCHASE'; + +interface CheckStatus { + callback: StatusCallback; +} + +type StatusCallback = (response: Status) => boolean; + +interface CheckInventory { + query: GrocerQuery; + callback: InventoryCallback; +} + +type GrocerQuery = { + variety: string; + quantity: number; +}; + +type InventoryCallback = ( + err: string | null, + isAvailable: boolean +) => AvailabilityAction; diff --git a/test/fixtures/fruit-picker/exemplar/grocer.js b/test/fixtures/fruit-picker/exemplar/grocer.js new file mode 100644 index 00000000..959dbeaf --- /dev/null +++ b/test/fixtures/fruit-picker/exemplar/grocer.js @@ -0,0 +1,70 @@ +/** + * STORE STATUS API + */ + +let storeStatus = 'OFFLINE'; + +/** + * For testing purposes, set the store status + * @param {string} status + */ +export function setStatus(status) { + storeStatus = status; +} + +/** + * For testing purposes, reset the store status + */ +export function resetStatus() { + storeStatus = 'OFFLINE'; +} + +/** + * Invokes the callback with the store's status to simulate an API call + * @param {StatusCallback} callback + */ +export function checkStatus(callback) { + return callback(storeStatus); +} + +/** + * INVENTORY API + */ + +let lastInventoryQuery = undefined; +let inventoryResponse = undefined; + +/** + * For testing purposes, set the response to return when queried + * @param {any} ...nextResponse + */ +export function setResponse(...nextResponse) { + inventoryResponse = nextResponse; +} + +/** + * For testing purposes, get the last query + * @return {string} + */ +export function getLastQuery() { + return lastInventoryQuery; +} + +/** + * For testing purposes, reset the last query + */ +export function resetQuery() { + lastInventoryQuery = undefined; + inventoryResponse = ['undefined response']; +} + +/** + * Checks the inventory (inventoryResponse) then invokes the callback with the result + * @param {GrocerQuery} query + * @param {InventoryCallback} callback + * @return {AvailabilityAction} return the result of the callback + */ +export function checkInventory(query, callback) { + lastInventoryQuery = query; + return callback.apply(null, inventoryResponse); +} diff --git a/test/fixtures/high-score-board/exemplar/.meta/config.json b/test/fixtures/high-score-board/exemplar/.meta/config.json new file mode 100644 index 00000000..065791fd --- /dev/null +++ b/test/fixtures/high-score-board/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Practice JavaScript objects by tracking high scores of an arcade game.", + "authors": ["junedev"], + "contributors": [], + "files": { + "solution": ["high-score-board.js"], + "test": ["high-score-board.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["elixir/high-score", "swift/high-score-board"] +} diff --git a/test/fixtures/high-score-board/exemplar/.meta/design.md b/test/fixtures/high-score-board/exemplar/.meta/design.md new file mode 100644 index 00000000..45307215 --- /dev/null +++ b/test/fixtures/high-score-board/exemplar/.meta/design.md @@ -0,0 +1,94 @@ +# Design + +## Learning objectives + +- What are objects - for now describe them as maps/dictionaries that hold key-value pairs, more advanced things will be taught later +- How to create an object literal with `{}` (either empty or with some initial values) +- What keys are allowed +- What values are allowed (e.g., numbers, strings etc or other objects, arrays or even functions) +- How to add a key-value pair to an object, how to change the value of an existing key +- How to retrieve the value (show `obj["key"]` and `obj.key` notation) +- How to retrieve nested values +- How to remove an entry +- How to check a key exists in the object with `hasOwnProperty` +- How to iterate through the keys with `for...in` + +## Out of Scope + +- Prototypes and classes +- `this` +- Object destructering +- null and undefined (will be introduced in another concept exercise, including when they show up in the context of objects, it is a bit hard to tiptoe around this in this concept/exercise but the student can't learn everything at once) +- `new Object()` / `Object.create` +- Symbols as keys (should be covered in a separate concept) +- JSON (should be covered in a separate concept) +- "pass by reference" (will be introduced in the `functions` concept) +- optional chaining (will be introduced in the `null-undefined` concept) + +## Concepts + +The Concepts this exercise unlocks are: + +- `objects` + +## Prerequisites + +- `for-loops` to better understand the `for...in` loop and because should already have covered some ground in the concept tree before starting with objects (e.g., arrays which are a prerequisite for `for-loops`) + +## Analyzer + +This exercise could benefit from the following rules in the [analyzer][analyzer]: + +1. `createScoreBoard` + + - `essential`: Make sure no class, map etc. was created, there should be just an object. + - `actionable`: If the student created an empty object first and then added the value, give feedback to include the entry in the object literal directly. + - `actionable`: Check that the object was returned directly, no intermediate assignment to a variable necessary. + +2. `addPlayer` + + - `essential`: Check the assignment operator was used and no additional variables were declared. + +3. `removePlayer` + + - `essential`: Make sure `delete` was used and not set to undefined or null. + - `actionable`: If there is additional code to check whether the key is present before deleting it, give feedback that this is not necessary. + +4. `updateScore` + + - `actionable`: If the student used a separate variable to calculate the new value first, tell them it is not necessary. + - `actionable`: If the student did not use the shorthand assignment operator, tell them about it. If they used it already, give a `celebratory` comment. + +5. `applyMondayBonus` + + - `essential`: Check the student actually used `for...in`. + - Same feedback as in `updateScore` applies. + - Using `updateScore` in the solution should be treated as equally correct as the exemplar solution. + +6. `normalizeScore` + + - `actionable`: No intermediate variables necessary. + +## Notes + +The exercise is inspired by the [High Score Board Exercise in the Swift track][swift-high-score]. +Some tasks were removed because they did not fit the JavaScript version or our concept tree. +The last two tasks were added instead in this exercise. + +## Improvement + +There are still a couple of things open that could be added to the about.md file. + +- Helper methods like `Object.assign`, `Object.defineProperty`, `Object.getOwnPropertyNames` etc. +- How to use an object as a map instead of a switch statement +- Object vs. Map +- See https://github.com/exercism/javascript/pull/1160#discussion_r654696799 + +Also the story in instructions.md could need some love, e.g., + +- Add names for the arcade hall, the game, the town. +- Maybe introduce a person instead of saying "you". +- Make sure all tasks relate back to the story. + +[analyzer]: https://github.com/exercism/javascript-analyzer +[swift-high-score]: https://github.com/exercism/swift/blob/main/exercises/concept/high-score-board/.docs/instructions.md diff --git a/test/fixtures/high-score-board/exemplar/.meta/exemplar.js b/test/fixtures/high-score-board/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..c0bc9550 --- /dev/null +++ b/test/fixtures/high-score-board/exemplar/.meta/exemplar.js @@ -0,0 +1,75 @@ +/// +// @ts-check + +/** + * Creates a new score board with an initial entry. + * + * @returns {Record} new score board + */ +export function createScoreBoard() { + return { + 'The Best Ever': 1000000, + }; +} + +/** + * Adds a player to a score board. + * + * @param {Record} scoreBoard + * @param {string} player + * @param {number} score + * @returns {Record} updated score board + */ +export function addPlayer(scoreBoard, player, score) { + scoreBoard[player] = score; + return scoreBoard; +} + +/** + * Removes a player from a score board. + * + * @param {Record} scoreBoard + * @param {string} player + * @returns {Record} updated score board + */ +export function removePlayer(scoreBoard, player) { + delete scoreBoard[player]; + return scoreBoard; +} + +/** + * Increases a player's score by the given amount. + * + * @param {Record} scoreBoard + * @param {string} player + * @param {number} points + * @returns {Record} updated score board + */ +export function updateScore(scoreBoard, player, points) { + scoreBoard[player] += points; + return scoreBoard; +} + +/** + * Applies 100 bonus points to all players on the board. + * + * @param {Record} scoreBoard + * @returns {Record} updated score board + */ +export function applyMondayBonus(scoreBoard) { + for (const player in scoreBoard) { + scoreBoard[player] += 100; + } + + return scoreBoard; +} + +/** + * Normalizes a score with the provided normalization function. + * + * @param {Params} params the parameters for performing the normalization + * @returns {number} normalized score + */ +export function normalizeScore(params) { + return params.normalizeFunction(params.score); +} diff --git a/test/fixtures/high-score-board/exemplar/global.d.ts b/test/fixtures/high-score-board/exemplar/global.d.ts new file mode 100644 index 00000000..d3b02ac0 --- /dev/null +++ b/test/fixtures/high-score-board/exemplar/global.d.ts @@ -0,0 +1,4 @@ +declare type Params = { + score: number; + normalizeFunction(score: number): number; +}; diff --git a/test/fixtures/high-score-board/exemplar/high-score-board.js b/test/fixtures/high-score-board/exemplar/high-score-board.js new file mode 100644 index 00000000..12555e64 --- /dev/null +++ b/test/fixtures/high-score-board/exemplar/high-score-board.js @@ -0,0 +1,75 @@ +/// +// @ts-check + +/** + * Creates a new score board with an initial entry. + * + * @returns {Record} new score board + */ +export function createScoreBoard() { + return { + 'The Best Ever': 1000000, + }; +} + +/** + * Adds a player to a score board. + * + * @param {Record} scoreBoard + * @param {string} player + * @param {number} score + * @returns {Record} updated score board + */ +export function addPlayer(scoreBoard, player, score) { + scoreBoard[player] = score; + return scoreBoard; +} + +/** + * Removes a player from a score board. + * + * @param {Record} scoreBoard + * @param {string} player + * @returns {Record} updated score board + */ +export function removePlayer(scoreBoard, player) { + delete scoreBoard[player]; + return scoreBoard; +} + +/** + * Increases a player's score by the given amount. + * + * @param {Record} scoreBoard + * @param {string} player + * @param {number} points + * @returns {Record} updated score board + */ +export function updateScore(scoreBoard, player, points) { + scoreBoard[player] += points; + return scoreBoard; +} + +/** + * Applies 100 bonus points to all players on the board. + * + * @param {Record} scoreBoard + * @returns {Record} updated score board + */ +export function applyMondayBonus(scoreBoard) { + for (const player in scoreBoard) { + scoreBoard[player] += 100; + } + + return scoreBoard; +} + +/** + * Normalizes a score with the provided normalization function. + * + * @param {Params} params the parameters for performing the normalization + * @returns {number} normalized score + */ +export function normalizeScore(params) { + return params.normalizeFunction(params.score); +} diff --git a/test/fixtures/high-score-board/exemplar/high-score-board.spec.js b/test/fixtures/high-score-board/exemplar/high-score-board.spec.js new file mode 100644 index 00000000..ea165e91 --- /dev/null +++ b/test/fixtures/high-score-board/exemplar/high-score-board.spec.js @@ -0,0 +1,144 @@ +import { + createScoreBoard, + addPlayer, + removePlayer, + updateScore, + applyMondayBonus, + normalizeScore, +} from './high-score-board'; + +describe('createScoreBoard', () => { + test('creates a new board with a test entry', () => { + const expected = { 'The Best Ever': 1000000 }; + expect(createScoreBoard()).toEqual(expected); + }); +}); + +describe('addPlayer', () => { + test('adds a player and score to the board', () => { + const scoreBoard = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 0, + }; + + const expected = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 0, + 'Jesse Johnson': 1337, + }; + + const actual = addPlayer(scoreBoard, 'Jesse Johnson', 1337); + expect(actual).toEqual(expected); + }); + + test('returns the existing score board', () => { + const scoreBoard = {}; + const actual = addPlayer(scoreBoard, 'Jesse Johnson', 1337); + // This follows the suggestion from the Jest docs to avoid a confusing test report + // https://jestjs.io/docs/expect#tobevalue + expect(Object.is(actual, scoreBoard)).toBe(true); + }); +}); + +describe('removePlayer', () => { + test('removes a player from the score board', () => { + const scoreBoard = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 0, + 'Jesse Johnson': 1337, + }; + + const expected = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 0, + }; + + const actual = removePlayer(scoreBoard, 'Jesse Johnson'); + expect(actual).toEqual(expected); + expect(Object.is(actual, scoreBoard)).toBe(true); + }); + + test('does nothing if the player is not on the board', () => { + const scoreBoard = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 0, + 'Jesse Johnson': 1337, + }; + + const actual = removePlayer(scoreBoard, 'Bruno Santangelo'); + expect(actual).toEqual(scoreBoard); + expect(Object.is(actual, scoreBoard)).toBe(true); + }); +}); + +describe('updateScore', () => { + test("increases a player's score", () => { + const scoreBoard = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 0, + 'Jesse Johnson': 1337, + }; + + const expected = { + 'Amil Pastorius': 99373, + 'Min-seo Shin': 1999, + 'Jesse Johnson': 2674, + }; + + updateScore(scoreBoard, 'Min-seo Shin', 1999); + const actual = updateScore(scoreBoard, 'Jesse Johnson', 1337); + expect(actual).toEqual(expected); + expect(Object.is(actual, scoreBoard)).toBe(true); + }); +}); + +describe('applyMondayBonus', () => { + test('adds 100 points for all players', () => { + const scoreBoard = { + 'Amil Pastorius': 345, + 'Min-seo Shin': 19, + 'Jesse Johnson': 122, + }; + + const expected = { + 'Amil Pastorius': 445, + 'Min-seo Shin': 119, + 'Jesse Johnson': 222, + }; + + const actual = applyMondayBonus(scoreBoard); + expect(actual).toEqual(expected); + expect(Object.is(actual, scoreBoard)).toBe(true); + }); + + test('does nothing if the score board is empty', () => { + const scoreBoard = {}; + const actual = applyMondayBonus(scoreBoard); + expect(actual).toEqual({}); + expect(Object.is(actual, scoreBoard)).toBe(true); + }); +}); + +describe('normalizeScore', () => { + test('applies the normalization function', () => { + const params = { + score: 45, + normalizeFunction: function (score) { + return score * 3 - 10; + }, + }; + + expect(normalizeScore(params)).toEqual(125); + }); + + test('works for different params', () => { + const params = { + score: 2100, + normalizeFunction: function (score) { + return score / 2 + 100; + }, + }; + + expect(normalizeScore(params)).toEqual(1150); + }); +}); diff --git a/test/fixtures/lasagna-master/exemplar/.meta/config.json b/test/fixtures/lasagna-master/exemplar/.meta/config.json new file mode 100644 index 00000000..6b24340d --- /dev/null +++ b/test/fixtures/lasagna-master/exemplar/.meta/config.json @@ -0,0 +1,12 @@ +{ + "blurb": "Dive deeper into JavaScript functions while preparing to cook the perfect lasagna.", + "authors": ["junedev"], + "contributors": [], + "files": { + "solution": ["lasagna-master.js"], + "test": ["lasagna-master.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "source": "Inspired by the Lasagna Master exercise in the Swift track", + "source_url": "https://github.com/exercism/swift/blob/main/exercises/concept/lasagna-master/.docs/instructions.md" +} diff --git a/test/fixtures/lasagna-master/exemplar/.meta/design.md b/test/fixtures/lasagna-master/exemplar/.meta/design.md new file mode 100644 index 00000000..3cdf8f6a --- /dev/null +++ b/test/fixtures/lasagna-master/exemplar/.meta/design.md @@ -0,0 +1,46 @@ +# Design + +## Learning objectives + +- How to define a function +- What happens if there is no return statement +- Return an object if you want to return multiple values +- All parameters are optional in JS +- What happens if the function is called with a different number of parameters than mentioned in the function definition +- How to set default parameters +- Pass by value vs. pass by reference +- What are function expressions +- Function scope (about.md) + +## Out of Scope + +The following topics will be introduced later and should therefore not be part of this concept exercise. + +- Rest parameters +- Callbacks +- Methods +- Arrow functions (will be introduced later together with callbacks) +- Recursion +- Closures +- Higher Order Functions +- Generators + +## Concepts + +The Concept this exercise unlocks is: + +- `functions` + +## Prerequisites + +- `null-undefined` because they are needed for return values, default params etc. +- `objects` are needed in the exercise +- `arrays` are needed in the exercise + +## Notes + +The story was inspired by the [Lasagna Master Exercise in the Swift track][swift-lasagna-master]. +Most tasks needed to be replaced though to achieve an exercise that fits the JavaScript learning objectives. + +[analyzer]: https://github.com/exercism/javascript-analyzer +[swift-lasagna-master]: https://github.com/exercism/swift/blob/main/exercises/concept/lasagna-master/.docs/instructions.md diff --git a/test/fixtures/lasagna-master/exemplar/.meta/exemplar.js b/test/fixtures/lasagna-master/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..50db8c16 --- /dev/null +++ b/test/fixtures/lasagna-master/exemplar/.meta/exemplar.js @@ -0,0 +1,91 @@ +/// +// @ts-check + +/** + * Determines whether the lasagna is done based on the + * remaining time on the timer. + * + * @param {number} time time left on the timer + * @returns {string} cooking status + */ +export function cookingStatus(time) { + if (time === undefined) { + return 'You forgot to set the timer.'; + } + + if (time === 0) { + return 'Lasagna is done.'; + } + + return 'Not done, please wait.'; +} + +/** + * Estimates the preparation time based on the number of layers. + * + * @param {string[]} layers + * @param {number} avgPrepTime + * @returns {number} total preparation time + */ +export function preparationTime(layers, avgPrepTime = 2) { + return layers.length * avgPrepTime; +} + +/** + * Calculates how many noodles and much sauce are needed for the + * given layers. + * + * @param {string[]} layers + * @returns {Quantities} quantities needed for the given layers + */ +export function quantities(layers) { + let noodles = 0; + let sauce = 0; + + for (let i = 0; i < layers.length; i++) { + if (layers[i] === 'noodles') { + noodles += 50; + } + + if (layers[i] === 'sauce') { + sauce += 0.2; + } + } + + return { noodles, sauce }; +} + +/** + * Adds the secret ingredient from the ingredient list that a + * friend provided to your ingredient list. + * + * @param {string[]} friendsList + * @param {string[]} myList + */ +export function addSecretIngredient(friendsList, myList) { + const lastIndex = friendsList.length - 1; + myList.push(friendsList[lastIndex]); +} + +/** + * Calculates the amounts of ingredients needed for a certain + * amount of portions. + * Assumes the original amounts were meant for 2 portions. + * Does not modify the original recipe. + * + * @param {Record} recipe + * @param {number} targetPortions + * @returns {Record} recipe with amounts for target portions + */ +export function scaleRecipe(recipe, targetPortions) { + const factor = targetPortions / 2; + + /** @type {Record} */ + const result = {}; + + for (const key in recipe) { + result[key] = recipe[key] * factor; + } + + return result; +} diff --git a/test/fixtures/lasagna-master/exemplar/global.d.ts b/test/fixtures/lasagna-master/exemplar/global.d.ts new file mode 100644 index 00000000..2181b9cd --- /dev/null +++ b/test/fixtures/lasagna-master/exemplar/global.d.ts @@ -0,0 +1,4 @@ +declare type Quantities = { + noodles: number; + sauce: number; +}; diff --git a/test/fixtures/lasagna-master/exemplar/lasagna-master.js b/test/fixtures/lasagna-master/exemplar/lasagna-master.js new file mode 100644 index 00000000..9128c7a1 --- /dev/null +++ b/test/fixtures/lasagna-master/exemplar/lasagna-master.js @@ -0,0 +1,91 @@ +/// +// @ts-check + +/** + * Determines whether the lasagna is done based on the + * remaining time on the timer. + * + * @param {number} time time left on the timer + * @returns {string} cooking status + */ +export function cookingStatus(time) { + if (time === undefined) { + return 'You forgot to set the timer.'; + } + + if (time === 0) { + return 'Lasagna is done.'; + } + + return 'Not done, please wait.'; +} + +/** + * Estimates the preparation time based on the number of layers. + * + * @param {string[]} layers + * @param {number} avgPrepTime + * @returns {number} total preparation time + */ +export function preparationTime(layers, avgPrepTime = 2) { + return layers.length * avgPrepTime; +} + +/** + * Calculates how many noodles and much sauce are needed for the + * given layers. + * + * @param {string[]} layers + * @returns {Quantities} quantities needed for the given layers + */ +export function quantities(layers) { + let noodles = 0; + let sauce = 0; + + for (let i = 0; i < layers.length; i++) { + if (layers[i] === 'noodles') { + noodles += 50; + } + + if (layers[i] === 'sauce') { + sauce += 0.2; + } + } + + return { noodles, sauce }; +} + +/** + * Adds the secret ingredient from the ingredient list that a + * friend provided to your ingredient list. + * + * @param {string[]} friendsList + * @param {string[]} myList + */ +export function addSecretIngredient(friendsList, myList) { + const lastIndex = friendsList.length - 1; + myList.push(friendsList[lastIndex]); +} + +/** + * Calculates the amounts of ingredients needed for a certain + * amount of portions. + * Assumes the original amounts were meant for 2 portions. + * Does not modify the original recipe. + * + * @param {Record} recipe + * @param {number} targetPortions + * @returns {Record} recipe with amounts for target portions + */ +export function scaleRecipe(recipe, targetPortions) { + const factor = targetPortions / 2; + + /** @type {Record} */ + const result = {}; + + for (const key in recipe) { + result[key] = recipe[key] * factor; + } + + return result; +} diff --git a/test/fixtures/lasagna-master/exemplar/lasagna-master.spec.js b/test/fixtures/lasagna-master/exemplar/lasagna-master.spec.js new file mode 100644 index 00000000..4d99f040 --- /dev/null +++ b/test/fixtures/lasagna-master/exemplar/lasagna-master.spec.js @@ -0,0 +1,241 @@ +import { + cookingStatus, + preparationTime, + quantities, + addSecretIngredient, + scaleRecipe, +} from './lasagna-master'; + +const DIFFERENCE_PRECISION_IN_DIGITS = 6; + +describe('cookingStatus', () => { + test('recognizes that there is time left on the timer', () => { + const expected = 'Not done, please wait.'; + expect(cookingStatus(1)).toBe(expected); + expect(cookingStatus(42)).toBe(expected); + expect(cookingStatus(8.5)).toBe(expected); + expect(cookingStatus(0.1)).toBe(expected); + }); + + test('recognizes when there is no time left on the timer', () => { + expect(cookingStatus(0)).toBe('Lasagna is done.'); + }); + + test('returns a special status when no timer value was passed', () => { + const expected = 'You forgot to set the timer.'; + expect(cookingStatus()).toBe(expected); + expect(cookingStatus(undefined)).toBe(expected); + }); +}); + +describe('preparationTime', () => { + test('applies the custom average time per layer', () => { + const manyLayers = [ + 'sauce', + 'noodles', + 'béchamel', + 'meat', + 'mozzarella', + 'noodles', + 'ricotta', + 'eggplant', + 'béchamel', + 'noodles', + 'sauce', + 'mozzarella', + ]; + expect(preparationTime(manyLayers, 1)).toBe(12); + + const fewLayers = ['sauce', 'noodles']; + expect(preparationTime(fewLayers, 3.5)).toBe(7); + }); + + test('uses the default if no customer time was passed', () => { + const manyLayers = [ + 'sauce', + 'noodles', + 'béchamel', + 'meat', + 'mozzarella', + 'noodles', + 'ricotta', + 'eggplant', + 'béchamel', + 'noodles', + 'sauce', + 'mozzarella', + ]; + expect(preparationTime(manyLayers)).toBe(24); + + const fewLayers = ['sauce', 'noodles']; + expect(preparationTime(fewLayers)).toBe(4); + }); + + test('works with an empty layers array', () => { + expect(preparationTime([])).toBe(0); + }); +}); + +describe('quantities', () => { + test('calculates the amounts of noodles and sauce correctly', () => { + const fewLayers = ['noodles', 'sauce', 'noodles']; + expectObjectsToBeEqual(quantities(fewLayers), { noodles: 100, sauce: 0.2 }); + + const manyLayers = [ + 'sauce', + 'noodles', + 'béchamel', + 'meat', + 'mozzarella', + 'noodles', + 'ricotta', + 'eggplant', + 'béchamel', + 'noodles', + 'sauce', + 'mozzarella', + ]; + expectObjectsToBeEqual(quantities(manyLayers), { + noodles: 150, + sauce: 0.4, + }); + }); + + test('works if there are no noodles or no sauce found in the layers', () => { + const noNoodles = ['sauce', 'béchamel', 'sauce', 'meat', 'sauce']; + expectObjectsToBeEqual(quantities(noNoodles), { noodles: 0, sauce: 0.6 }); + + const noSauce = ['eggplant', 'béchamel', 'noodles', 'béchamel']; + expectObjectsToBeEqual(quantities(noSauce), { noodles: 50, sauce: 0 }); + }); + + test('works with an empty layers array', () => { + expect(quantities([])).toEqual({ noodles: 0, sauce: 0 }); + }); +}); + +describe('addSecretIngredient', () => { + test('adds the secret ingredient to the second array', () => { + const friendsList = ['sauce', 'noodles', 'béchamel', 'marjoram']; + const myList = ['sauce', 'noodles', 'meat', 'tomatoes']; + addSecretIngredient(friendsList, myList); + + const expected = ['sauce', 'noodles', 'meat', 'tomatoes', 'marjoram']; + expect(myList).toEqual(expected); + }); + + test('does not modify the first array', () => { + const createFriendsList = () => [ + 'noodles', + 'tomatoes', + 'sauce', + 'meat', + 'mozzarella', + 'eggplant', + 'ricotta', + 'parmesan', + ]; + + const friendsList = createFriendsList(); + const myList = ['ricotta', 'béchamel', 'sauce', 'noodles', 'meat']; + addSecretIngredient(friendsList, myList); + + expect(friendsList).toEqual(createFriendsList()); + }); + + test('does not return anything', () => { + const friendsList = [ + 'sauce', + 'noodles', + 'béchamel', + 'mozzarella', + 'mustard', + ]; + const myList = ['sauce', 'noodles', 'tomatoes']; + expect(addSecretIngredient(friendsList, myList)).toBeUndefined(); + }); +}); + +describe('scaleRecipe', () => { + test('scales up correctly', () => { + const recipe1 = { + sauce: 0.5, + noodles: 250, + meat: 150, + tomatoes: 3, + onion: 0.5, + }; + + const expected1 = { + sauce: 1.5, + noodles: 750, + meat: 450, + tomatoes: 9, + onion: 1.5, + }; + + expectObjectsToBeEqual(scaleRecipe(recipe1, 6), expected1); + + // prettier-ignore + const recipe2 = { + 'sauce': 0.6, + 'noodles': 300, + 'carrots': 1, + 'mozzarella': 0.5, + 'ricotta': 50, + 'béchamel': 0.1, + 'tofu': 100, + }; + + // prettier-ignore + const expected2 = { + 'sauce': 0.9, + 'noodles': 450, + 'carrots': 1.5, + 'mozzarella': 0.75, + 'ricotta': 75, + 'béchamel': 0.15, + 'tofu': 150, + }; + + expectObjectsToBeEqual(scaleRecipe(recipe2, 3), expected2); + }); + + test('scales down correctly', () => { + const recipe = { + sauce: 0.5, + noodles: 250, + meat: 150, + tomatoes: 3, + onion: 0.5, + }; + + const expected = { + sauce: 0.25, + noodles: 125, + meat: 75, + tomatoes: 1.5, + onion: 0.25, + }; + expectObjectsToBeEqual(scaleRecipe(recipe, 1), expected); + }); + + test('works for an empty recipe', () => { + expect(scaleRecipe({})).toEqual({}); + }); +}); + +/** + * Jest does not support comparing objects that contain floating point number values. + * https://github.com/facebook/jest/issues/3654 + * This helper functions applies "toBeCloseTo" to compare object values. + */ +function expectObjectsToBeEqual(actualObj, expectedObj) { + for (const key in expectedObj) { + expect(actualObj[key]).toBeCloseTo( + expectedObj[key], + DIFFERENCE_PRECISION_IN_DIGITS + ); + } + expect(Object.keys(actualObj).length).toBe(Object.keys(expectedObj).length); +} diff --git a/test/fixtures/lucky-numbers/exemplar/.meta/config.json b/test/fixtures/lucky-numbers/exemplar/.meta/config.json new file mode 100644 index 00000000..37542eef --- /dev/null +++ b/test/fixtures/lucky-numbers/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Practice type conversion while helping your friend Kojo with his website www.fun-with-numbers.com.", + "authors": ["shubhsk88", "junedev"], + "contributors": ["neenjaw", "SleeplessByte"], + "files": { + "solution": ["lucky-numbers.js"], + "test": ["lucky-numbers.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": [] +} diff --git a/test/fixtures/lucky-numbers/exemplar/.meta/design.md b/test/fixtures/lucky-numbers/exemplar/.meta/design.md new file mode 100644 index 00000000..1d338848 --- /dev/null +++ b/test/fixtures/lucky-numbers/exemplar/.meta/design.md @@ -0,0 +1,34 @@ +# Design + +## Learning objectives + +- What is the difference between type conversion and type coercion +- Converting primitive types using the global functions `Boolean`, `Number` and `String` +- Pitfall: `String` on objects (pointer towards JSON) +- How to define the serialization behavior yourself +- When does type coercion happen (to boolean, to string or to number) +- Pitfalls of type coercion + +## Out of Scope + +The following topics should be covered in other concepts and are therefore not part of this concept exercise. + +- Boxed vs. non-boxed primitives (could potentially become part of this concept in the future) +- `Date` conversions +- Details on JSON +- `new Function` +- Details on `parseInt` and `parseFloat` + +Details on the conversion logic for loose equality are also out of scope as they are not required knowledge to write good code/ be fluent. + +## Concepts + +The Concept this exercise unlocks is: + +- `type-conversion` + +## Prerequisites + +- `booleans`, `numbers`, `strings` because that is what we convert from and to +- `arrays` because they are needed in the exercise +- `null-undefined` because they are needed to understand how those values convert diff --git a/test/fixtures/lucky-numbers/exemplar/.meta/exemplar.js b/test/fixtures/lucky-numbers/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..eddd45dc --- /dev/null +++ b/test/fixtures/lucky-numbers/exemplar/.meta/exemplar.js @@ -0,0 +1,46 @@ +// @ts-check + +/** + * Calculates the sum of the numbers represented by the two input arrays. + * + * @param {number[]} array1 + * @param {number[]} array2 + * @returns {number} sum of the two arrays + */ +export function twoSum(array1, array2) { + const firstNumber = array1.join(''); + const secondNumber = array2.join(''); + + return Number(firstNumber) + Number(secondNumber); +} + +/** + * Checks whether a number is a palindrome. + * + * @param {number} value + * @returns {boolean} whether the number is a palindrome or not + */ +export function luckyNumber(value) { + const strValue = String(value); + + return strValue === strValue.split('').reverse().join(''); +} + +/** + * Determines the error message that should be shown to the user + * for the given input value. + * + * @param {string|null|undefined} input + * @returns {string} error message + */ +export function errorMessage(input) { + if (!input) { + return 'Required field'; + } + + if (!Number(input)) { + return 'Must be a number besides 0'; + } + + return ''; +} diff --git a/test/fixtures/lucky-numbers/exemplar/lucky-numbers.js b/test/fixtures/lucky-numbers/exemplar/lucky-numbers.js new file mode 100644 index 00000000..eddd45dc --- /dev/null +++ b/test/fixtures/lucky-numbers/exemplar/lucky-numbers.js @@ -0,0 +1,46 @@ +// @ts-check + +/** + * Calculates the sum of the numbers represented by the two input arrays. + * + * @param {number[]} array1 + * @param {number[]} array2 + * @returns {number} sum of the two arrays + */ +export function twoSum(array1, array2) { + const firstNumber = array1.join(''); + const secondNumber = array2.join(''); + + return Number(firstNumber) + Number(secondNumber); +} + +/** + * Checks whether a number is a palindrome. + * + * @param {number} value + * @returns {boolean} whether the number is a palindrome or not + */ +export function luckyNumber(value) { + const strValue = String(value); + + return strValue === strValue.split('').reverse().join(''); +} + +/** + * Determines the error message that should be shown to the user + * for the given input value. + * + * @param {string|null|undefined} input + * @returns {string} error message + */ +export function errorMessage(input) { + if (!input) { + return 'Required field'; + } + + if (!Number(input)) { + return 'Must be a number besides 0'; + } + + return ''; +} diff --git a/test/fixtures/lucky-numbers/exemplar/lucky-numbers.spec.js b/test/fixtures/lucky-numbers/exemplar/lucky-numbers.spec.js new file mode 100644 index 00000000..801bc6d5 --- /dev/null +++ b/test/fixtures/lucky-numbers/exemplar/lucky-numbers.spec.js @@ -0,0 +1,54 @@ +import { twoSum, luckyNumber, errorMessage } from './lucky-numbers'; + +describe('twoSum', () => { + test('sums the numbers correctly for short arrays', () => { + const leftInput = [2, 4]; + const rightInput = [1, 5, 7]; + expect(twoSum(leftInput, rightInput)).toBe(181); + }); + + test('sums the numbers correctly for long arrays', () => { + const leftInput = [1, 2, 4, 0, 3, 5, 2, 9]; + const rightInput = [3, 2, 4, 8, 1, 5, 4, 1, 8]; + expect(twoSum(leftInput, rightInput)).toBe(337218947); + }); +}); + +describe('luckyNumber', () => { + test('identifies palindromic numbers', () => { + expect(luckyNumber(15651)).toBe(true); + expect(luckyNumber(48911984)).toBe(true); + }); + + test('identifies non-palindromic numbers', () => { + expect(luckyNumber(156512)).toBe(false); + expect(luckyNumber(48921984)).toBe(false); + }); + + test('works for small numbers', () => { + expect(luckyNumber(0)).toBe(true); + expect(luckyNumber(33)).toBe(true); + expect(luckyNumber(12)).toBe(false); + }); +}); + +describe('errorMessage', () => { + test('identifies if there is no input value', () => { + expect(errorMessage('')).toBe('Required field'); + expect(errorMessage(null)).toBe('Required field'); + expect(errorMessage(undefined)).toBe('Required field'); + }); + + test('identifies invalid inputs', () => { + expect(errorMessage('some text')).toBe('Must be a number besides 0'); + expect(errorMessage('86f1')).toBe('Must be a number besides 0'); + expect(errorMessage('4 2')).toBe('Must be a number besides 0'); + expect(errorMessage('0')).toBe('Must be a number besides 0'); + }); + + test('returns an empty string for valid inputs', () => { + expect(errorMessage('1.234')).toBe(''); + expect(errorMessage(' 784 ')).toBe(''); + expect(errorMessage('5e3')).toBe(''); + }); +}); diff --git a/test/fixtures/mixed-juices/exemplar/.meta/config.json b/test/fixtures/mixed-juices/exemplar/.meta/config.json new file mode 100644 index 00000000..5581bc90 --- /dev/null +++ b/test/fixtures/mixed-juices/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Help your friend Li Mei run her juice bar with your knowledge of while loops and switch statements.", + "authors": ["junedev"], + "contributors": [], + "files": { + "solution": ["mixed-juices.js"], + "test": ["mixed-juices.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["swift/master-mixologist"] +} diff --git a/test/fixtures/mixed-juices/exemplar/.meta/design.md b/test/fixtures/mixed-juices/exemplar/.meta/design.md new file mode 100644 index 00000000..5eb2a9b4 --- /dev/null +++ b/test/fixtures/mixed-juices/exemplar/.meta/design.md @@ -0,0 +1,90 @@ +# Design + +## Learning objectives + +- What does a while loop do +- What is the difference to do-while +- Syntax `while(){}` and `do{} while()` +- Break and continue +- What is the switch statement +- Syntax `switch(){}` +- What is the `default` case for +- What does `break` do +- Why is break so important when using switch + +## Out of Scope + +The following topics are out of scope because they are covered by another concept exercise. + +- For loops +- Array loops (for...in, forEach, map) + +## Concepts + +The Concepts this exercise unlocks are: + +- `while-loops` +- `conditionals-switch` + +## Prerequisites + +- `comparison` for writing the condition in the loop header +- `conditionals` because they introduced the student to the concept of conditional execution +- `arrays` because they are used to loop over them in the exercise + +## Analyzer + +This exercise could benefit from the following rules in the [analyzer][analyzer]. +The comment types mentioned below only serve as a proposal. + +1. `timeToMixJuice` + + - `essential`: Verify the student used a switch statement. + Would be nice if we could give different feedback depending on what the student used instead. + If it was if-else, comment that switch is better suited for so many different variants. + If an object was used, comment that this is nice but the goal is to practice switch. + - `essential`: Verify that there are 5 cases and a default case in the switch statement to make sure the student did not tailor their code for the test cases (e.g., used more cases instead of default). + - `actionable`: If the student used `break`, comment to use early `return`s instead to avoid assigning to a helper variable first and then returning that variable. + - `celebratory`: Comment something nice when a student used grouped case statements. + ```javascript + case 'Energizer': + case 'Green Garden': + return 1.5; + ``` + +2. `limesToCut` + + - A solution that uses `if (limes.length < 0) break;` instead of combining the conditions should be considered equally correct to the exemplar solution. + The version in the exemplar file is shorter but the break version emphasizes that there is a special edge case. + - `essential`: Verify that `while` was used. + - `essential`: If a helper function was used for the switch statement, check that is was not exported. + - `actionable`: If the student wrote `if (limes.length < 0) return limesCut`, comment that the duplication of `return limesCut` can be avoided by using break there instead of return. + - `actionable`: Tell the student to use a helper function to wrap the switch statement for readability if he/she did not do that. + - `informative`: If the student used a counter to iterate over the array, show a comment about about `shift`. + - `informative`: Remind the student about `++` if it was not used to increment the lime counter. + - `informative`: Check whether a shorthand assignment `+=` was used to increase the loop counter. + - `informative`: If `default` was included in the switch statement, remind the student that it is optional and not needed in the scope of the task. + - `celebratory`: Make a positive remark if the student used a helper function to wrap the switch statement. + - `celebratory`: Celebrate if the student used `++` and `+=`. + +3. `remainingOrders` + + - `essential`: Verify that do-while was used. + If while was used instead, say that do-while is a better fit because there is always at least one iteration (because `timeLeft` is always > 0) and the condition can best be checked after running the code. + - `essential`: Verify `timeToMixJuice` was reused instead of duplicating the code. + - Most of the points from task 2 also apply here. + Check what can be reused. + +## Notes + +The exercise is inspired by the [Master Mixologist Exercise in the Swift track][swift-master-mixologist]. +The original exercise also included for loops which is covered by a different exercise in the JavaScript track. +The tasks were adapted accordingly which also simplified them. +The alcohol was replaced and the name was changed to match the new story. + +## Improvement + +The exercise would benefit from another task to practice `continue`. + +[analyzer]: https://github.com/exercism/javascript-analyzer +[swift-master-mixologist]: https://github.com/exercism/swift/blob/main/exercises/concept/master-mixologist/.docs/instructions.md diff --git a/test/fixtures/mixed-juices/exemplar/.meta/exemplar.js b/test/fixtures/mixed-juices/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..348db8e9 --- /dev/null +++ b/test/fixtures/mixed-juices/exemplar/.meta/exemplar.js @@ -0,0 +1,79 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Determines how long it takes to prepare a certain juice. + * + * @param {string} name + * @returns {number} time in minutes + */ +export function timeToMixJuice(name) { + switch (name) { + case 'Pure Strawberry Joy': + return 0.5; + case 'Energizer': + return 1.5; + case 'Green Garden': + return 1.5; + case 'Tropical Island': + return 3; + case 'All or Nothing': + return 5; + default: + return 2.5; + } +} + +/** + * Calculates the number of limes that need to be cut + * to reach a certain supply. + * + * @param {number} wedgesNeeded + * @param {string[]} limes + * @returns {number} number of limes cut + */ +export function limesToCut(wedgesNeeded, limes) { + let limesCut = 0; + while (wedgesNeeded > 0 && limes.length > 0) { + limesCut++; + wedgesNeeded -= wedgesFromLime(limes.shift()); + } + + return limesCut; +} + +/** + * Determines the number of wedges that can be cut + * from a lime of the given size. + * + * @param {string} size + * @returns number of wedges + */ +function wedgesFromLime(size) { + switch (size) { + case 'small': + return 6; + case 'medium': + return 8; + case 'large': + return 10; + } +} + +/** + * Determines which juices still need to be prepared after the end of the shift. + * + * @param {number} timeLeft + * @param {string[]} orders + * @returns {string[]} remaining orders after the time is up + */ +export function remainingOrders(timeLeft, orders) { + do { + timeLeft -= timeToMixJuice(orders.shift()); + } while (timeLeft > 0 && orders.length > 0); + + return orders; +} diff --git a/test/fixtures/mixed-juices/exemplar/mixed-juices.js b/test/fixtures/mixed-juices/exemplar/mixed-juices.js new file mode 100644 index 00000000..348db8e9 --- /dev/null +++ b/test/fixtures/mixed-juices/exemplar/mixed-juices.js @@ -0,0 +1,79 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Determines how long it takes to prepare a certain juice. + * + * @param {string} name + * @returns {number} time in minutes + */ +export function timeToMixJuice(name) { + switch (name) { + case 'Pure Strawberry Joy': + return 0.5; + case 'Energizer': + return 1.5; + case 'Green Garden': + return 1.5; + case 'Tropical Island': + return 3; + case 'All or Nothing': + return 5; + default: + return 2.5; + } +} + +/** + * Calculates the number of limes that need to be cut + * to reach a certain supply. + * + * @param {number} wedgesNeeded + * @param {string[]} limes + * @returns {number} number of limes cut + */ +export function limesToCut(wedgesNeeded, limes) { + let limesCut = 0; + while (wedgesNeeded > 0 && limes.length > 0) { + limesCut++; + wedgesNeeded -= wedgesFromLime(limes.shift()); + } + + return limesCut; +} + +/** + * Determines the number of wedges that can be cut + * from a lime of the given size. + * + * @param {string} size + * @returns number of wedges + */ +function wedgesFromLime(size) { + switch (size) { + case 'small': + return 6; + case 'medium': + return 8; + case 'large': + return 10; + } +} + +/** + * Determines which juices still need to be prepared after the end of the shift. + * + * @param {number} timeLeft + * @param {string[]} orders + * @returns {string[]} remaining orders after the time is up + */ +export function remainingOrders(timeLeft, orders) { + do { + timeLeft -= timeToMixJuice(orders.shift()); + } while (timeLeft > 0 && orders.length > 0); + + return orders; +} diff --git a/test/fixtures/mixed-juices/exemplar/mixed-juices.spec.js b/test/fixtures/mixed-juices/exemplar/mixed-juices.spec.js new file mode 100644 index 00000000..3dbb3031 --- /dev/null +++ b/test/fixtures/mixed-juices/exemplar/mixed-juices.spec.js @@ -0,0 +1,113 @@ +import { timeToMixJuice, limesToCut, remainingOrders } from './mixed-juices'; + +describe('timeToMixJuice', () => { + test("returns the correct time for 'Pure Strawberry Joy'", () => { + expect(timeToMixJuice('Pure Strawberry Joy')).toBe(0.5); + }); + + test('returns the correct times for all other standard menu items', () => { + expect(timeToMixJuice('Energizer')).toBe(1.5); + expect(timeToMixJuice('Green Garden')).toBe(1.5); + expect(timeToMixJuice('Tropical Island')).toBe(3); + expect(timeToMixJuice('All or Nothing')).toBe(5); + }); + + test('returns the same time for all other juices', () => { + const defaultTime = 2.5; + expect(timeToMixJuice('Limetime')).toBe(defaultTime); + expect(timeToMixJuice('Manic Organic')).toBe(defaultTime); + expect(timeToMixJuice('Papaya & Peach')).toBe(defaultTime); + }); +}); + +describe('limesToCut', () => { + test('calculates the number of limes needed to reach the target supply', () => { + const limes = [ + 'small', + 'large', + 'large', + 'medium', + 'small', + 'large', + 'large', + 'medium', + ]; + expect(limesToCut(42, limes)).toBe(6); + + expect(limesToCut(4, ['medium', 'small'])).toBe(1); + }); + + test('uses up all limes if there are not enough to reach the target', () => { + const limes = [ + 'small', + 'large', + 'large', + 'medium', + 'small', + 'large', + 'large', + ]; + + expect(limesToCut(80, limes)).toBe(7); + }); + + test('if no new wedges are needed, no limes are cut', () => { + expect(limesToCut(0, ['small', 'large', 'medium'])).toBe(0); + }); + + test('works if no limes are available', () => { + expect(limesToCut(10, [])).toBe(0); + }); +}); + +describe('remainingOrders', () => { + test('correctly determines the remaining orders', () => { + const orders = [ + 'Tropical Island', + 'Energizer', + 'Limetime', + 'All or Nothing', + 'Pure Strawberry Joy', + ]; + const expected = ['All or Nothing', 'Pure Strawberry Joy']; + + expect(remainingOrders(7, orders)).toEqual(expected); + }); + + test('correctly handles orders that were started because there was time left', () => { + const orders = [ + 'Pure Strawberry Joy', + 'Pure Strawberry Joy', + 'Vitality', + 'Tropical Island', + 'All or Nothing', + 'All or Nothing', + 'All or Nothing', + 'Green Garden', + 'Limetime', + ]; + const expected = ['All or Nothing', 'Green Garden', 'Limetime']; + + expect(remainingOrders(13, orders)).toEqual(expected); + }); + + test('counts all orders as fulfilled if there is enough time', () => { + const orders = [ + 'Energizer', + 'Green Garden', + 'Ruby Glow', + 'Pure Strawberry Joy', + 'Tropical Island', + 'Limetime', + ]; + + expect(remainingOrders(12, orders)).toEqual([]); + }); + + test('works if there is only very little time left', () => { + const orders = ['Bananas Gone Wild', 'Pure Strawberry Joy']; + const expected = ['Pure Strawberry Joy']; + + expect(remainingOrders(0.2, orders)).toEqual(expected); + }); +}); diff --git a/test/fixtures/nullability/exemplar/.meta/config.json b/test/fixtures/nullability/exemplar/.meta/config.json new file mode 100644 index 00000000..27c8da08 --- /dev/null +++ b/test/fixtures/nullability/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "TODO: add blurb for nullability exercise", + "authors": ["SleeplessByte", "Jlamon"], + "contributors": [], + "files": { + "solution": ["nullability.js"], + "test": ["nullability.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["csharp/tim-from-marketing"] +} diff --git a/test/fixtures/nullability/exemplar/.meta/exemplar.js b/test/fixtures/nullability/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..cbd7da81 --- /dev/null +++ b/test/fixtures/nullability/exemplar/.meta/exemplar.js @@ -0,0 +1,17 @@ +/** + * Determines the text to print on a badge + * + * @param {number | null} id id of the employee, or null if they're new hires + * @param {string} name the name of the employee + * @param {string | null} department the department or null if they're the owner + * + * @returns {string} the text to print on the badge + */ +export function printBadge(id, name, department) { + const worksAt = department?.toLocaleUpperCase() || 'OWNER'; + + // prettier-ignore + return id === null + ? `${name} - ${worksAt}` + : `[${id}] ${name} - ${worksAt}`; +} diff --git a/test/fixtures/nullability/exemplar/nullability.js b/test/fixtures/nullability/exemplar/nullability.js new file mode 100644 index 00000000..cbd7da81 --- /dev/null +++ b/test/fixtures/nullability/exemplar/nullability.js @@ -0,0 +1,17 @@ +/** + * Determines the text to print on a badge + * + * @param {number | null} id id of the employee, or null if they're new hires + * @param {string} name the name of the employee + * @param {string | null} department the department or null if they're the owner + * + * @returns {string} the text to print on the badge + */ +export function printBadge(id, name, department) { + const worksAt = department?.toLocaleUpperCase() || 'OWNER'; + + // prettier-ignore + return id === null + ? `${name} - ${worksAt}` + : `[${id}] ${name} - ${worksAt}`; +} diff --git a/test/fixtures/nullability/exemplar/nullability.spec.js b/test/fixtures/nullability/exemplar/nullability.spec.js new file mode 100644 index 00000000..a6cf6233 --- /dev/null +++ b/test/fixtures/nullability/exemplar/nullability.spec.js @@ -0,0 +1,31 @@ +import { printBadge } from './nullability'; + +describe('nullability', () => { + describe('printBadge', () => { + it("printBadge(17, 'Ryder Herbert', 'Marketing')", () => { + const actual = printBadge(17, 'Ryder Herbert', 'Marketing'); + expect(actual).toBe('[17] Ryder Herbert - MARKETING'); + }); + }); + + describe('printBadge without an employee ID', () => { + it("printBadge(null, 'Bogdan Rosario', 'Marketing')", () => { + const actual = printBadge(null, 'Bogdan Rosario', 'Marketing'); + expect(actual).toBe('Bogdan Rosario - MARKETING'); + }); + }); + + describe('printBadge without a department', () => { + it("printBadge(59, 'Julie Sokato', null)", () => { + const actual = printBadge(59, 'Julie Sokato', null); + expect(actual).toBe('[59] Julie Sokato - OWNER'); + }); + }); + + describe('printBadge for a new owner', () => { + it("printBadge(null, 'Amare Osei', null)", () => { + const actual = printBadge(null, 'Amare Osei', null); + expect(actual).toBe('Amare Osei - OWNER'); + }); + }); +}); diff --git a/test/fixtures/ozans-playlist/exemplar/.meta/config.json b/test/fixtures/ozans-playlist/exemplar/.meta/config.json new file mode 100644 index 00000000..e2d42f62 --- /dev/null +++ b/test/fixtures/ozans-playlist/exemplar/.meta/config.json @@ -0,0 +1,10 @@ +{ + "blurb": "Use sets to avoid repeating tracks in a playlist", + "authors": ["kristinaborn"], + "contributors": [], + "files": { + "solution": ["ozans-playlist.js"], + "test": ["ozans-playlist.spec.js"], + "exemplar": [".meta/exemplar.js"] + } +} diff --git a/test/fixtures/ozans-playlist/exemplar/.meta/design.md b/test/fixtures/ozans-playlist/exemplar/.meta/design.md new file mode 100644 index 00000000..79f9bc0f --- /dev/null +++ b/test/fixtures/ozans-playlist/exemplar/.meta/design.md @@ -0,0 +1,45 @@ +# Design + +## Learning objectives + +- Know how to use a set to remove duplicate elements from an array +- Know how to convert between a set and an array +- Know how to check whether a value is in a set +- Know how to add and remove elements from a set +- Know how to iterate over a set +- Understand when a set might be preferable to an array + +## Out of Scope + +- Implementing common set operations like `union` and `difference` +- `WeakSet` + +## Concepts + +The Concepts this exercise unlocks are: + +- `sets` + +## Prerequisites + +- `array-destructuring` because examples use array destructuring +- `comparison` because this is where equality is explained +- `array-loops` because it introduces the for-of loop +- `rest-and-spread` +- `arrays` + +## Analyzer + +This exercise could benefit from the following rules in the [analyzer][analyzer]: + +For all tasks, verify that the student actually used a `Set`. + +1. `addTrack` + + - Verify that there was no redundant `Set.has()` call + +2. `deleteTrack` + + - Verify that there was no redundant `Set.has()` call + +[analyzer]: https://github.com/exercism/javascript-analyzer diff --git a/test/fixtures/ozans-playlist/exemplar/.meta/exemplar.js b/test/fixtures/ozans-playlist/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..86a71f46 --- /dev/null +++ b/test/fixtures/ozans-playlist/exemplar/.meta/exemplar.js @@ -0,0 +1,70 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Removes duplicate tracks from a playlist. + * + * @param {string[]} playlist + * @returns {string[]} new playlist with unique tracks + */ +export function removeDuplicates(playlist) { + return [...new Set(playlist)]; +} + +/** + * Checks whether a playlist includes a track. + * + * @param {string[]} playlist + * @param {string} track + * @returns {boolean} whether the track is in the playlist + */ +export function hasTrack(playlist, track) { + return new Set(playlist).has(track); +} + +/** + * Adds a track to a playlist. + * + * @param {string[]} playlist + * @param {string} track + * @returns {string[]} new playlist + */ +export function addTrack(playlist, track) { + return [...new Set(playlist).add(track)]; +} + +/** + * Deletes a track from a playlist. + * + * @param {string[]} playlist + * @param {string} track + * @returns {string[]} new playlist + */ +export function deleteTrack(playlist, track) { + const trackSet = new Set(playlist); + + trackSet.delete(track); + + return [...trackSet]; +} + +/** + * Returns the list of unique artists in a playlist + * + * @param {string[]} playlist + * @returns {string[]} unique artists + */ +export function listArtists(playlist) { + const artists = new Set(); + + for (let track of playlist.values()) { + const [, artist] = track.split(' - '); + + artists.add(artist); + } + + return [...artists]; +} diff --git a/test/fixtures/ozans-playlist/exemplar/ozans-playlist.js b/test/fixtures/ozans-playlist/exemplar/ozans-playlist.js new file mode 100644 index 00000000..86a71f46 --- /dev/null +++ b/test/fixtures/ozans-playlist/exemplar/ozans-playlist.js @@ -0,0 +1,70 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Removes duplicate tracks from a playlist. + * + * @param {string[]} playlist + * @returns {string[]} new playlist with unique tracks + */ +export function removeDuplicates(playlist) { + return [...new Set(playlist)]; +} + +/** + * Checks whether a playlist includes a track. + * + * @param {string[]} playlist + * @param {string} track + * @returns {boolean} whether the track is in the playlist + */ +export function hasTrack(playlist, track) { + return new Set(playlist).has(track); +} + +/** + * Adds a track to a playlist. + * + * @param {string[]} playlist + * @param {string} track + * @returns {string[]} new playlist + */ +export function addTrack(playlist, track) { + return [...new Set(playlist).add(track)]; +} + +/** + * Deletes a track from a playlist. + * + * @param {string[]} playlist + * @param {string} track + * @returns {string[]} new playlist + */ +export function deleteTrack(playlist, track) { + const trackSet = new Set(playlist); + + trackSet.delete(track); + + return [...trackSet]; +} + +/** + * Returns the list of unique artists in a playlist + * + * @param {string[]} playlist + * @returns {string[]} unique artists + */ +export function listArtists(playlist) { + const artists = new Set(); + + for (let track of playlist.values()) { + const [, artist] = track.split(' - '); + + artists.add(artist); + } + + return [...artists]; +} diff --git a/test/fixtures/ozans-playlist/exemplar/ozans-playlist.spec.js b/test/fixtures/ozans-playlist/exemplar/ozans-playlist.spec.js new file mode 100644 index 00000000..af8e09ae --- /dev/null +++ b/test/fixtures/ozans-playlist/exemplar/ozans-playlist.spec.js @@ -0,0 +1,100 @@ +import { + addTrack, + deleteTrack, + hasTrack, + listArtists, + removeDuplicates, +} from './ozans-playlist'; + +describe("Ozan's playlist", () => { + describe('removeDuplicates', () => { + test('works for an empty playlist', () => { + const playlist = []; + + expect(removeDuplicates(playlist)).toEqual([]); + }); + + test('works for a non-empty playlist', () => { + const TRACK_1 = 'Two Paintings and a Drum - Carl Cox'; + const TRACK_2 = 'Leash Called Love - The Sugarcubes'; + const playlist = [TRACK_1, TRACK_2, TRACK_1]; + const expected = [TRACK_1, TRACK_2]; + + expect(removeDuplicates(playlist)).toEqual(expected); + }); + }); + + describe('hasTrack', () => { + const TRACK_1 = 'Big Science - Laurie Anderson'; + const TRACK_2 = 'Tightrope - Laurie Anderson'; + + test('returns true when the track is in the playlist', () => { + const playlist = [TRACK_1, TRACK_2]; + + expect(hasTrack(playlist, TRACK_1)).toBe(true); + }); + + test('returns false when the track is not in the playlist', () => { + const playlist = [TRACK_2]; + + expect(hasTrack(playlist, TRACK_1)).toBe(false); + }); + }); + + describe('addTrack', () => { + const TRACK_1 = 'Jigsaw Feeling - Siouxsie and the Banshees'; + const TRACK_2 = 'Feeling Good - Nina Simone'; + + test('adds a track that is not already in the playlist', () => { + const playlist = []; + const expected = [TRACK_1]; + + expect(addTrack(playlist, TRACK_1)).toEqual(expected); + }); + + test('does not add a track that is already in the playlist', () => { + const playlist = [TRACK_1, TRACK_2]; + const expected = [TRACK_1, TRACK_2]; + + expect(addTrack(playlist, TRACK_1)).toEqual(expected); + }); + }); + + describe('deleteTrack', () => { + const TRACK_1 = 'Ancestors - Tanya Tagaq'; + const TRACK_2 = 'Take This Hammer - Lead Belly'; + + test('works if the track is present in the playlist', () => { + const playlist = [TRACK_1, TRACK_2]; + const expected = [TRACK_2]; + + expect(deleteTrack(playlist, TRACK_1)).toEqual(expected); + }); + + test('works if the track is not present in the playlist', () => { + const playlist = [TRACK_2]; + const expected = [TRACK_2]; + + expect(deleteTrack(playlist, TRACK_1)).toEqual(expected); + }); + }); + + describe('listArtists', () => { + test('works for an empty playlist', () => { + const playlist = []; + + expect(listArtists(playlist)).toEqual([]); + }); + + test('works for a non-empty playlist', () => { + const playlist = [ + 'Onu Alma Beni Al - Sezen Aksu', + 'Famous Blue Raincoat - Leonard Cohen', + 'Rakkas - Sezen Aksu', + ]; + const expected = ['Sezen Aksu', 'Leonard Cohen']; + + expect(listArtists(playlist)).toEqual(expected); + }); + }); +}); diff --git a/test/fixtures/pizza-order/exemplar/.meta/config.json b/test/fixtures/pizza-order/exemplar/.meta/config.json new file mode 100644 index 00000000..51a8889d --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "TODO: add blurb for recursion exercise", + "authors": ["SleeplessByte"], + "contributors": [], + "files": { + "solution": ["pizza-order.js"], + "test": ["pizza-order.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["fsharp/pizza-pricing"] +} diff --git a/test/fixtures/pizza-order/exemplar/.meta/design.md b/test/fixtures/pizza-order/exemplar/.meta/design.md new file mode 100644 index 00000000..aac88c17 --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/.meta/design.md @@ -0,0 +1,27 @@ +# Design + +## Learning objectives + +- TBD + +## Out of Scope + +TBD + +## Concepts + +The Concept this exercise unlocks is: + +- `recursion` + +## Prerequisites + +- TBD + +## Analyzer + +This exercise could benefit from the following rules in the [analyzer][analyzer]: + +TBD + +[analyzer]: https://github.com/exercism/javascript-analyzer diff --git a/test/fixtures/pizza-order/exemplar/.meta/env.d.ts b/test/fixtures/pizza-order/exemplar/.meta/env.d.ts new file mode 100644 index 00000000..4630316d --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/.meta/env.d.ts @@ -0,0 +1,4 @@ +/// + +// This file purely exist so that exemplar.js may live in this folder, without +// that causing syntax errors. diff --git a/test/fixtures/pizza-order/exemplar/.meta/exemplar.js b/test/fixtures/pizza-order/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..a5e7ca2f --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/.meta/exemplar.js @@ -0,0 +1,47 @@ +/// + +// @ts-check + +/** + * @type {Record} + */ +const PIZZA_PRICES = { + Margherita: 7, + Caprese: 9, + Formaggio: 10, +}; + +/** + * Determine the prize of the pizza given the pizza and optional extras + * + * @param {Pizza} pizza name of the pizza to be made + * @param {Extra[]} extras list of extras + * + * @returns {number} the price of the pizza + */ +export function pizzaPrice(pizza, ...[extra, ...otherExtras]) { + switch (extra) { + case 'ExtraSauce': { + return 1 + pizzaPrice(pizza, ...otherExtras); + } + case 'ExtraToppings': { + return 2 + pizzaPrice(pizza, ...otherExtras); + } + default: { + return PIZZA_PRICES[pizza]; + } + } +} + +/** + * Calculate the prize of the total order, given individual orders + * + * @param {PizzaOrder[]} pizzaOrders a list of pizza orders + * @returns {number} the price of the total order + */ +export function orderPrice(pizzaOrders) { + return pizzaOrders.reduce( + (result, order) => result + pizzaPrice(order.pizza, ...order.extras), + 0 + ); +} diff --git a/test/fixtures/pizza-order/exemplar/global.d.ts b/test/fixtures/pizza-order/exemplar/global.d.ts new file mode 100644 index 00000000..93ee4ca9 --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/global.d.ts @@ -0,0 +1,3 @@ +type Pizza = 'Margherita' | 'Caprese' | 'Formaggio'; +type Extra = 'ExtraSauce' | 'ExtraToppings'; +type PizzaOrder = { pizza: Pizza; extras: Extra[] }; diff --git a/test/fixtures/pizza-order/exemplar/pizza-order.js b/test/fixtures/pizza-order/exemplar/pizza-order.js new file mode 100644 index 00000000..9eddb15a --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/pizza-order.js @@ -0,0 +1,46 @@ +/// +// @ts-check + +/** + * @type {Record} + */ +const PIZZA_PRICES = { + Margherita: 7, + Caprese: 9, + Formaggio: 10, +}; + +/** + * Determine the prize of the pizza given the pizza and optional extras + * + * @param {Pizza} pizza name of the pizza to be made + * @param {Extra[]} extras list of extras + * + * @returns {number} the price of the pizza + */ +export function pizzaPrice(pizza, ...[extra, ...otherExtras]) { + switch (extra) { + case 'ExtraSauce': { + return 1 + pizzaPrice(pizza, ...otherExtras); + } + case 'ExtraToppings': { + return 2 + pizzaPrice(pizza, ...otherExtras); + } + default: { + return PIZZA_PRICES[pizza]; + } + } +} + +/** + * Calculate the prize of the total order, given individual orders + * + * @param {PizzaOrder[]} pizzaOrders a list of pizza orders + * @returns {number} the price of the total order + */ +export function orderPrice(pizzaOrders) { + return pizzaOrders.reduce( + (result, order) => result + pizzaPrice(order.pizza, ...order.extras), + 0 + ); +} diff --git a/test/fixtures/pizza-order/exemplar/pizza-order.spec.js b/test/fixtures/pizza-order/exemplar/pizza-order.spec.js new file mode 100644 index 00000000..98c8adf8 --- /dev/null +++ b/test/fixtures/pizza-order/exemplar/pizza-order.spec.js @@ -0,0 +1,171 @@ +import { pizzaPrice, orderPrice } from './pizza-order'; + +class PizzaOrder { + /** + * + * @param {Pizza} pizza + * @param {Extra[]} extras + */ + constructor(pizza, ...extras) { + this.pizza = pizza; + this.extras = Object.freeze(extras); + + Object.freeze(this); + } +} + +describe('Price for pizza margherita', () => { + it("pizzaPrice('Margherita')", () => { + expect(pizzaPrice('Margherita')).toBe(7); + }); +}); + +describe('Price for pizza formaggio', () => { + it("pizzaPrice('Formaggio')", () => { + expect(pizzaPrice('Formaggio')).toBe(10); + }); +}); + +describe('Price for pizza caprese', () => { + it("pizzaPrice('Caprese')", () => { + expect(pizzaPrice('Caprese')).toBe(9); + }); +}); + +describe('Price for pizza margherita with extra sauce', () => { + it("pizzaPrice('Margherita', 'ExtraSauce')", () => { + expect(pizzaPrice('Margherita', 'ExtraSauce')).toBe(8); + }); +}); + +describe('Price for pizza caprese with extra toppings', () => { + it("pizzaPrice('Caprese', 'ExtraToppings')", () => { + expect(pizzaPrice('Caprese', 'ExtraToppings')).toBe(11); + }); +}); + +describe('Price for pizza formaggio with extra sauce and toppings', () => { + it("pizzaPrice('Formaggio', 'ExtraSauce', 'ExtraToppings')", () => { + expect(pizzaPrice('Formaggio', 'ExtraSauce', 'ExtraToppings')).toBe(13); + }); +}); + +describe('Price for pizza caprese with extra sauce and toppings', () => { + it("pizzaPrice('Caprese', 'ExtraSauce', 'ExtraToppings')", () => { + expect(pizzaPrice('Caprese', 'ExtraSauce', 'ExtraToppings')).toBe(12); + }); +}); + +describe('Price for pizza caprese with a lot of extra toppings', () => { + it("pizzaPrice('Caprese', 'ExtraToppings', 'ExtraToppings', 'ExtraToppings', 'ExtraToppings')", () => { + expect( + pizzaPrice( + 'Caprese', + 'ExtraToppings', + 'ExtraToppings', + 'ExtraToppings', + 'ExtraToppings' + ) + ).toBe(17); + }); +}); + +describe('Order price for no pizzas', () => { + it('orderPrice([])', () => { + expect(orderPrice([])).toBe(0); + }); +}); + +describe('Order price for a single pizza caprese', () => { + it("orderPrice([PizzaOrder('Caprese')])", () => { + const order = new PizzaOrder('Caprese'); + expect(orderPrice([order])).toBe(9); + }); +}); + +describe('Order price for a single pizza formaggio with extra sauce', () => { + it("orderPrice([PizzaOrder('Formaggio', 'ExtraSauce')])", () => { + const order = new PizzaOrder('Formaggio', 'ExtraSauce'); + expect(orderPrice([order])).toBe(11); + }); +}); + +describe('Order price for one pizza margherita and one pizza caprese with extra toppings', () => { + it("orderPrice([PizzaOrder('Margherita'), PizzaOrder('Caprese', 'ExtraToppings')])", () => { + const margherita = new PizzaOrder('Margherita'); + const caprese = new PizzaOrder('Caprese', 'ExtraToppings'); + + expect(orderPrice([margherita, caprese])).toBe(18); + + // Also test that the order doesn't matter + expect(orderPrice([caprese, margherita])).toBe(18); + }); +}); + +describe('Order price for one pizza margherita with a LOT of sauce and one pizza caprese with a LOT of toppings', () => { + it("orderPrice([PizzaOrder('Margherita', 'ExtraSauce', 'ExtraSauce', 'ExtraSauce'), PizzaOrder('Caprese', 'ExtraToppings', 'ExtraToppings', 'ExtraToppings', 'ExtraToppings')])", () => { + const saucyMargherita = new PizzaOrder( + 'Margherita', + 'ExtraSauce', + 'ExtraSauce', + 'ExtraSauce' + ); + const toppedCaprese = new PizzaOrder( + 'Caprese', + 'ExtraToppings', + 'ExtraToppings', + 'ExtraToppings', + 'ExtraToppings' + ); + + expect(orderPrice([saucyMargherita, toppedCaprese])).toBe(27); + + // Also test that the order doesn't matter + expect(orderPrice([toppedCaprese, saucyMargherita])).toBe(27); + }); +}); + +describe('Order price for very large order', () => { + it('orderPrice([/* lots of */])', () => { + const margherita = new PizzaOrder('Margherita'); + const margherita2 = new PizzaOrder('Margherita', 'ExtraSauce'); + const caprese = new PizzaOrder('Caprese'); + const caprese2 = new PizzaOrder('Caprese', 'ExtraToppings'); + const formaggio = new PizzaOrder('Formaggio'); + const formaggio2 = new PizzaOrder('Formaggio', 'ExtraSauce'); + const formaggio3 = new PizzaOrder( + 'Formaggio', + 'ExtraSauce', + 'ExtraToppings' + ); + const formaggio4 = new PizzaOrder( + 'Formaggio', + 'ExtraToppings', + 'ExtraSauce', + 'ExtraToppings', + 'ExtraSauce' + ); + + const actual = orderPrice([ + margherita, + margherita2, + caprese, + caprese2, + formaggio, + formaggio2, + formaggio3, + formaggio4, + ]); + expect(actual).toBe(85); + }); +}); + +describe('Order price for a gigantic order', () => { + it('orderPrice([/* lots of */])', () => { + const allTheMargheritas = Array(100 * 1000).fill( + new PizzaOrder('Margherita') + ); + const actual = orderPrice(allTheMargheritas); + expect(actual).toBe(700 * 1000); + }); +}); diff --git a/test/fixtures/translation-service/exemplar/.meta/config.json b/test/fixtures/translation-service/exemplar/.meta/config.json new file mode 100644 index 00000000..c6e4a724 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/.meta/config.json @@ -0,0 +1,12 @@ +{ + "blurb": "Connect to the Klingon Translation Service and learn about promises.", + "authors": ["SleeplessByte"], + "contributors": [], + "files": { + "solution": ["service.js"], + "test": ["service.spec.js"], + "exemplar": [".meta/exemplar.js"], + "editor": ["api.js", "errors.js", "global.d.ts"] + }, + "forked_from": [] +} diff --git a/test/fixtures/translation-service/exemplar/.meta/env.d.ts b/test/fixtures/translation-service/exemplar/.meta/env.d.ts new file mode 100644 index 00000000..4630316d --- /dev/null +++ b/test/fixtures/translation-service/exemplar/.meta/env.d.ts @@ -0,0 +1,4 @@ +/// + +// This file purely exist so that exemplar.js may live in this folder, without +// that causing syntax errors. diff --git a/test/fixtures/translation-service/exemplar/.meta/exemplar.alternative.js b/test/fixtures/translation-service/exemplar/.meta/exemplar.alternative.js new file mode 100644 index 00000000..0809b223 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/.meta/exemplar.alternative.js @@ -0,0 +1,115 @@ +// @ts-check + +export class TranslationService { + /** + * + * @param {ExternalApi} api + */ + constructor(api) { + this.api = api; + } + + /** + * Attempts to retrieve the translation for the given text. + * + * - Returns whichever translation can be retrieved, regardless the quality + * - Forwards any error from the translation api + * + * @param {string} text + * @returns {Promise} + */ + async free(text) { + const { translation } = await this.api.fetch(text); + return translation; + } + + /** + * Retrieves the translation for the given text + * + * - Rejects with an error if the quality can not be met + * - Requests a translation if the translation is not available, then retries + * + * @param {string} text + * @param {number} minimumQuality + * @returns {Promise} + */ + async premium(text, minimumQuality) { + try { + const { translation, quality } = await this.api.fetch(text); + + if (minimumQuality > quality) { + throw new QualityThresholdNotMet(text); + } + return translation; + } catch (err) { + if (err instanceof QualityThresholdNotMet) { + throw err; + } + + await this.request(text); + return await this.premium(text, minimumQuality); + } + } + + /** + * Batch translates the given texts using the free service. + * + * - Resolves all the translations (in the same order), if they all succeed + * - Rejects with the first error that is encountered + * - Rejects with a BatchIsEmpty error if no texts are given + * + * @param {string[]} texts + * @returns {Promise} + */ + async batch(texts) { + if (texts.length === 0) { + throw new BatchIsEmpty(); + } + + return await Promise.all(texts.map(this.free.bind(this))); + } + + /** + * Requests the service for some text to be translated, retrying a few times + * in case of a rejection. + * + * @param {string} text + * @param {number} [attempt=1] + * @returns {Promise} + */ + async request(text, attempt = 1) { + try { + await new Promise((resolve, reject) => { + this.api.request(text, (err) => { + err ? reject(err) : resolve(); + }); + }); + } catch (err) { + if (attempt === 3) { + throw err; + } + + return await this.request(text, attempt + 1); + } + } +} + +export class QualityThresholdNotMet extends Error { + constructor(text) { + super( + ` +The translation of ${text} does not meet the requested quality threshold. + `.trim() + ); + } +} + +export class BatchIsEmpty extends Error { + constructor() { + super( + ` +Requested a batch translation, but there are no texts in the batch. + `.trim() + ); + } +} diff --git a/test/fixtures/translation-service/exemplar/.meta/exemplar.js b/test/fixtures/translation-service/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..ca477b96 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/.meta/exemplar.js @@ -0,0 +1,109 @@ +/// +// @ts-check + +export class TranslationService { + /** + * + * @param {ExternalApi} api + */ + constructor(api) { + this.api = api; + } + + /** + * Attempts to retrieve the translation for the given text. + * + * - Returns whichever translation can be retrieved, regardless the quality + * - Forwards any error from the translation api + * + * @param {string} text + * @returns {Promise} + */ + free(text) { + return this.api.fetch(text).then(({ translation }) => translation); + } + + /** + * Retrieves the translation for the given text + * + * - Rejects with an error if the quality can not be met + * - Requests a translation if the translation is not available, then retries + * + * @param {string} text + * @param {number} minimumQuality + * @returns {Promise} + */ + premium(text, minimumQuality) { + return this.api.fetch(text).then( + ({ translation, quality }) => { + if (minimumQuality > quality) { + throw new QualityThresholdNotMet(text); + } + + return translation; + }, + () => this.request(text).then(() => this.premium(text, minimumQuality)) + ); + } + + /** + * Batch translates the given texts using the free service. + * + * - Resolves all the translations (in the same order), if they all succeed + * - Rejects with the first error that is encountered + * - Rejects with a BatchIsEmpty error if no texts are given + * + * @param {string[]} texts + */ + batch(texts) { + if (texts.length === 0) { + return Promise.reject(new BatchIsEmpty()); + } + + return Promise.all(texts.map(this.free.bind(this))); + } + + /** + * Requests the service for some text to be translated, retrying a few times + * in case of a rejection. + * + * @param {string} text + * @param {number} [attempt=1] + * @returns {Promise} + */ + request(text, attempt = 1) { + return new Promise((resolve, reject) => { + this.api.request(text, (err) => { + if (err) { + if (attempt < 3) { + return this.request(text, attempt + 1).then(resolve, reject); + } + + return reject(err); + } + + return resolve(); + }); + }); + } +} + +export class QualityThresholdNotMet extends Error { + constructor(text) { + super( + ` +The translation of ${text} does not meet the requested quality threshold. + `.trim() + ); + } +} + +export class BatchIsEmpty extends Error { + constructor() { + super( + ` +Requested a batch translation, but there are no texts in the batch. + `.trim() + ); + } +} diff --git a/test/fixtures/translation-service/exemplar/api.js b/test/fixtures/translation-service/exemplar/api.js new file mode 100644 index 00000000..de99e747 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/api.js @@ -0,0 +1,100 @@ +import { AbusiveClientError, NotAvailable, Untranslatable } from './errors'; + +const mutex = { current: false }; + +/** + * @typedef {{ translation: string, quality: number }} Translation + * @typedef {Record>} TranslatableValues + * + */ +export class ExternalApi { + /** + * @param {Readonly} values + */ + constructor(values = {}) { + /** @type {TranslatableValues} */ + this.values = JSON.parse(JSON.stringify(values)); + } + + /** + * Register a word for translation + * + * @param {string} value + * @param {string | null} translation + * @param {number | undefined} quality + * + * @returns {this} + */ + register(value, translation, quality = undefined) { + if (typeof this.values[value] === 'undefined') { + this.values[value] = []; + } + + this.values[value].push(translation ? { translation, quality } : null); + return this; + } + + /** + * @param {string} text + * @returns {Promise} + */ + fetch(text) { + // Check if client is banned + if (mutex.current) { + return rejectWithRandomDelay(new AbusiveClientError()); + } + + if (this.values[text] && this.values[text][0]) { + return resolveWithRandomDelay(this.values[text][0]); + } + + if (this.values[text]) { + return rejectWithRandomDelay(new NotAvailable(text)); + } + + return rejectWithRandomDelay(new Untranslatable()); + } + + /** + * @param {string} text + * @param {(err?: Error) => void} callback + */ + request(text, callback) { + if (this.values[text] && this.values[text][0]) { + mutex.current = true; + callback(new AbusiveClientError()); + return; + } + + if (this.values[text]) { + this.values[text].shift(); + + // If it's now available, yay, otherwise, nay + setTimeout( + () => callback(this.values[text][0] ? undefined : makeRandomError()), + 1 + ); + return; + } + + callback(new Untranslatable()); + } +} + +function resolveWithRandomDelay(value) { + const timeout = Math.random() * 100; + return new Promise((resolve) => { + setTimeout(() => resolve(value), timeout); + }); +} + +function rejectWithRandomDelay(value) { + const timeout = Math.random() * 100; + return new Promise((_, reject) => { + setTimeout(() => reject(value), timeout); + }); +} + +function makeRandomError() { + return new Error(`Error code ${Math.ceil(Math.random() * 10000)}`); +} diff --git a/test/fixtures/translation-service/exemplar/errors.js b/test/fixtures/translation-service/exemplar/errors.js new file mode 100644 index 00000000..7c98b921 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/errors.js @@ -0,0 +1,27 @@ +export class NotAvailable extends Error { + constructor(text) { + super( + ` +The requested text "${text}" has not been translated yet. + `.trim() + ); + } +} + +export class AbusiveClientError extends Error { + constructor() { + super( + ` +Your client has been rejected because of abusive behaviour. + +naDevvo’ yIghoS! + `.trim() + ); + } +} + +export class Untranslatable extends Error { + constructor() { + super('jIyajbe’'); + } +} diff --git a/test/fixtures/translation-service/exemplar/global.d.ts b/test/fixtures/translation-service/exemplar/global.d.ts new file mode 100644 index 00000000..52764679 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/global.d.ts @@ -0,0 +1,21 @@ +/** + * These are the shapes of the external service', the return values and the + * functions. Don't change these. In various IDEs, such as vscode, this will add + * type information on the fly + */ + +interface ExternalApi { + fetch: fetchTranslation; + request: requestTranslation; +} + +interface Translation { + translation: string; + quality: number; +} + +type fetchTranslation = (text: string) => Promise; +type requestTranslation = ( + text: string, + callback: (err?: Error) => void +) => void; diff --git a/test/fixtures/translation-service/exemplar/service.js b/test/fixtures/translation-service/exemplar/service.js new file mode 100644 index 00000000..31e2b610 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/service.js @@ -0,0 +1,109 @@ +/// +// @ts-check + +export class TranslationService { + /** + * + * @param {ExternalApi} api + */ + constructor(api) { + this.api = api; + } + + /** + * Attempts to retrieve the translation for the given text. + * + * - Returns whichever translation can be retrieved, regardless the quality + * - Forwards any error from the translation api + * + * @param {string} text + * @returns {Promise} + */ + free(text) { + return this.api.fetch(text).then(({ translation }) => translation); + } + + /** + * Retrieves the translation for the given text + * + * - Rejects with an error if the quality can not be met + * - Requests a translation if the translation is not available, then retries + * + * @param {string} text + * @param {number} minimumQuality + * @returns {Promise} + */ + premium(text, minimumQuality) { + return this.api.fetch(text).then( + ({ translation, quality }) => { + if (minimumQuality > quality) { + throw new QualityThresholdNotMet(text); + } + + return translation; + }, + () => this.request(text).then(() => this.premium(text, minimumQuality)) + ); + } + + /** + * Batch translates the given texts using the free service. + * + * - Resolves all the translations (in the same order), if they all succeed + * - Rejects with the first error that is encountered + * - Rejects with a BatchIsEmpty error if no texts are given + * + * @param {string[]} texts + */ + batch(texts) { + if (texts.length === 0) { + return Promise.reject(new BatchIsEmpty()); + } + + return Promise.all(texts.map(this.free.bind(this))); + } + + /** + * Requests the service for some text to be translated, retrying a few times + * in case of a rejection. + * + * @param {string} text + * @param {number} [attempt=1] + * @returns {Promise} + */ + request(text, attempt = 1) { + return new Promise((resolve, reject) => { + this.api.request(text, (err) => { + if (err) { + if (attempt < 3) { + return this.request(text, attempt + 1).then(resolve, reject); + } + + return reject(err); + } + + return resolve(); + }); + }); + } +} + +export class QualityThresholdNotMet extends Error { + constructor(text) { + super( + ` +The translation of ${text} does not meet the requested quality threshold. + `.trim() + ); + } +} + +export class BatchIsEmpty extends Error { + constructor() { + super( + ` +Requested a batch translation, but there are no texts in the batch. + `.trim() + ); + } +} diff --git a/test/fixtures/translation-service/exemplar/service.spec.js b/test/fixtures/translation-service/exemplar/service.spec.js new file mode 100644 index 00000000..51f64101 --- /dev/null +++ b/test/fixtures/translation-service/exemplar/service.spec.js @@ -0,0 +1,205 @@ +// @ts-check + +import { + TranslationService, + QualityThresholdNotMet, + BatchIsEmpty, +} from './service'; + +import { NotAvailable, Untranslatable } from './errors'; +import { ExternalApi } from './api'; + +describe('Free service', () => { + /** @type {TranslationService} */ + let service; + + beforeEach(() => { + const api = new ExternalApi() + .register('jIyaj', 'I understand', 100) + .register('jIyajbe’', null) + .register('jIyajbe’', "I don't understand", 100); + + service = new TranslationService(api); + }); + + test('it can translate a known word group', () => { + const actual = service.free('jIyaj'); + const expected = 'I understand'; + + expect(actual).resolves.toBe(expected); + }); + + test('it forwards NotAvailable errors from the API, unaltered', () => { + const actual = service.free('jIyajbe’'); + const expected = NotAvailable; + + expect(actual).rejects.toThrow(expected); + }); + + test('it forwards Untranslatable errors from the API, unaltered', () => { + const actual = service.free('majQa’'); + const expected = Untranslatable; + + expect(actual).rejects.toThrow(expected); + }); +}); + +describe('Batch service', () => { + /** @type {TranslationService} */ + let service; + + beforeEach(() => { + // jIyajbe’ will be marked as not yet translated, but is not translatable + const api = new ExternalApi({ 'jIyajbe’': [] }) + .register('jIyaj', 'I understand', 100) + .register('majQa’', 'Well done!', 100); + + service = new TranslationService(api); + }); + + test('it can translate a batch', () => { + const actual = service.batch(['jIyaj', 'majQa’']); + const expected = ['I understand', 'Well done!']; + + expect(actual).resolves.toStrictEqual(expected); + }); + + test('it maintains the order of batch input', () => { + const actual = service.batch(['majQa’', 'jIyaj']); + const expected = ['Well done!', 'I understand']; + + expect(actual).resolves.toStrictEqual(expected); + }); + + test('it works with just one item to translate', () => { + const actual = service.batch(['jIyaj']); + const expected = ['I understand']; + + expect(actual).resolves.toStrictEqual(expected); + }); + + test('it throws if one or more translations fail', () => { + const actual = service.batch(['jIyaj', 'jIyajbe’', 'majQa’']); + const expected = NotAvailable; + + expect(actual).rejects.toThrow(expected); + }); + + test('it throws on an empty input', () => { + const actual = service.batch([]); + const expected = BatchIsEmpty; + + expect(actual).rejects.toThrow(expected); + }); +}); + +describe('Request service', () => { + /** @type {TranslationService} */ + let service; + + beforeEach(() => { + const api = new ExternalApi() + .register('majQa’', null) + .register('majQa’', 'Well done!', 100) + .register('jIyajbe’', null) + .register('jIyajbe’', null) + .register('jIyajbe’', null) + .register('jIyajbe’', "I don't understand", 100) + .register('ghobe’', null) + .register('ghobe’', null) + .register('ghobe’', null) + .register('ghobe’', null) + .register('ghobe’', 'No!', 100); + + service = new TranslationService(api); + }); + + test('it can request something that is not available, but eventually is', () => { + const actual = service.request('majQa’'); + expect(actual).resolves.toBeUndefined(); + }); + + test('it eventually rejects when something is not translatable', () => { + const actual = service.request('foo'); + const expected = Untranslatable; + + expect(actual).rejects.toThrow(expected); + }); + + test('it requests up to three times (retries once or twice)', () => { + const actual = service.request('jIyajbe’'); + expect(actual).resolves.toBeUndefined(); + }); + + test('it requests at most three times (does not retry thrice or more)', () => { + const actual = service.request('ghobe’'); + + expect(actual).rejects.toThrow(Error); + }); +}); + +describe('Premium service', () => { + /** @type {TranslationService} */ + let service; + + beforeEach(() => { + const api = new ExternalApi() + .register('majQa’', 'Well done', 90) + .register('jIyajbe’', null) + .register('jIyajbe’', "I don't understand", 100) + .register('ghobe’', null) + .register('ghobe’', null) + .register('ghobe’', null) + .register('ghobe’', null) + .register('ghobe’', 'No!', 100) + .register('‘arlogh Qoylu’pu’?', null) + .register('‘arlogh Qoylu’pu’?', 'What time is it?', 75); + + service = new TranslationService(api); + }); + + test('it can resolve a translation', () => { + const actual = service.premium('majQa’', 0); + const expected = 'Well done'; + + expect(actual).resolves.toBe(expected); + }); + + test('it requests unavailable translations and then resolves', () => { + const actual = service.premium('jIyajbe’', 0); + const expected = "I don't understand"; + + expect(actual).resolves.toBe(expected); + }); + + test('it rejects with Untranslatable if the premium service fails to translate', () => { + const actual = service.premium('foo', 0); + const expected = Untranslatable; + + expect(actual).rejects.toThrow(expected); + }); + + test('it requests at most three times (does not retry thrice or more)', () => { + const actual = service.premium('ghobe’', 0); + + expect(actual).rejects.toThrow(Error); + }); + + test('it ensures the quality of the translation', () => { + const actual = service.premium('majQa’', 100); + const expected = QualityThresholdNotMet; + + expect(actual).rejects.toThrow(expected); + }); + + test('it ensures the quality even after a request', () => { + const actual = service.premium('‘arlogh Qoylu’pu’?', 40); + const expected = 'What time is it?'; + + expect(actual).resolves.toBe(expected); + + const actualQuality = service.premium('‘arlogh Qoylu’pu’?', 100); + const expectedQuality = QualityThresholdNotMet; + expect(actualQuality).rejects.toThrow(expectedQuality); + }); +}); diff --git a/test/fixtures/vehicle-purchase/exemplar/.meta/config.json b/test/fixtures/vehicle-purchase/exemplar/.meta/config.json new file mode 100644 index 00000000..05135d81 --- /dev/null +++ b/test/fixtures/vehicle-purchase/exemplar/.meta/config.json @@ -0,0 +1,11 @@ +{ + "blurb": "Learn about comparison and conditionals while preparing for your next vehicle purchase", + "authors": ["junedev"], + "contributors": [], + "files": { + "solution": ["vehicle-purchase.js"], + "test": ["vehicle-purchase.spec.js"], + "exemplar": [".meta/exemplar.js"] + }, + "forked_from": ["julia/vehicle-purchase"] +} diff --git a/test/fixtures/vehicle-purchase/exemplar/.meta/design.md b/test/fixtures/vehicle-purchase/exemplar/.meta/design.md new file mode 100644 index 00000000..f5964f25 --- /dev/null +++ b/test/fixtures/vehicle-purchase/exemplar/.meta/design.md @@ -0,0 +1,57 @@ +# Design + +## Learning objectives + +### Comparison + +- How to compare numbers and strings with relational operators (`<`, `>`, `>=`, `<=`) +- Equality checks with strict equals (`===`) and not strict equals (`!==`) + +### Conditionals + +How to write if-statements + +- `if(){}` +- `if(){} else{}` +- `if(){} else if(){} else{}` + +## Out of Scope + +- Details about loose equality `==` and truthy/falsy (can be taught later when the student learn more about type coercion) +- Shallow/deep comparison of objects +- Ternary operator `x ? y : z` (will be taught later to avoid overloading this early exercise) +- `switch`, `for` + +## Concepts + +The Concepts this exercise unlocks are: + +- `comparison` +- `conditionals` + +## Prerequisites + +- `booleans` because they are the result of the comparison and used in the conditions +- `numbers` because they are used to practice comparison +- `strings` also because they are used to practice comparison + +## Analyzer + +This exercise could benefit from the following rules in the [analyzer][analyzer]: + +- Verify that `needsLicense` does not include an unnecessary if-statement where the student returns `true`/`false`. +- Verify that in `chooseVehicle` the string `' is clearly the better choice'` only appears once. +- Verify that in `chooseVehicle` and `calculateResellPrice` the student actually practiced if/else and did not use early returns. E.g., show a comment like this + ``` + Nice. + That's an _early return_. + For the purpose of the Concept that this exercise aims to teach, try solving this using an `else` statement. + ``` + +## Notes + +The exercise is inspired by [Vehicle Purchase Exercise in the Julia track][julia-vehicle-purchase] but the original exercise included more concepts and the tasks were more difficult to transfer into code. +To keep the concept exercise rather trivial as it should be, the tasks were extremely simplified or replaced. + +[analyzer]: https://github.com/exercism/javascript-analyzer +[julia-vehicle-purchase]: https://github.com/exercism/julia/blob/main/exercises/concept/vehicle-purchase/.docs/instructions.md diff --git a/test/fixtures/vehicle-purchase/exemplar/.meta/exemplar.js b/test/fixtures/vehicle-purchase/exemplar/.meta/exemplar.js new file mode 100644 index 00000000..00aaf1db --- /dev/null +++ b/test/fixtures/vehicle-purchase/exemplar/.meta/exemplar.js @@ -0,0 +1,55 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Determines whether or not you need a licence to operate a certain kind of vehicle. + * + * @param {string} kind + * @returns {boolean} whether a license is required + */ +export function needsLicense(kind) { + return kind === 'car' || kind === 'truck'; +} + +/** + * Helps choosing between two options by recommending the one that + * comes first in dictionary order. + * + * @param {string} option1 + * @param {string} option2 + * @returns {string} a sentence of advice which option to choose + */ +export function chooseVehicle(option1, option2) { + let selection; + if (option1 < option2) { + selection = option1; + } else { + selection = option2; + } + + return selection + ' is clearly the better choice.'; +} + +/** + * Calculates an estimate for the price of a used vehicle in the dealership + * based on the original price and the age of the vehicle. + * + * @param {number} originalPrice + * @param {number} age + * @returns expected resell price in the dealership + */ +export function calculateResellPrice(originalPrice, age) { + let percentage; + if (age < 3) { + percentage = 80; + } else if (age > 10) { + percentage = 50; + } else { + percentage = 70; + } + + return (percentage / 100) * originalPrice; +} diff --git a/test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.js b/test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.js new file mode 100644 index 00000000..00aaf1db --- /dev/null +++ b/test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.js @@ -0,0 +1,55 @@ +// @ts-check +// +// The line above enables type checking for this file. Various IDEs interpret +// the @ts-check directive. It will give you helpful autocompletion when +// implementing this exercise. + +/** + * Determines whether or not you need a licence to operate a certain kind of vehicle. + * + * @param {string} kind + * @returns {boolean} whether a license is required + */ +export function needsLicense(kind) { + return kind === 'car' || kind === 'truck'; +} + +/** + * Helps choosing between two options by recommending the one that + * comes first in dictionary order. + * + * @param {string} option1 + * @param {string} option2 + * @returns {string} a sentence of advice which option to choose + */ +export function chooseVehicle(option1, option2) { + let selection; + if (option1 < option2) { + selection = option1; + } else { + selection = option2; + } + + return selection + ' is clearly the better choice.'; +} + +/** + * Calculates an estimate for the price of a used vehicle in the dealership + * based on the original price and the age of the vehicle. + * + * @param {number} originalPrice + * @param {number} age + * @returns expected resell price in the dealership + */ +export function calculateResellPrice(originalPrice, age) { + let percentage; + if (age < 3) { + percentage = 80; + } else if (age > 10) { + percentage = 50; + } else { + percentage = 70; + } + + return (percentage / 100) * originalPrice; +} diff --git a/test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.spec.js b/test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.spec.js new file mode 100644 index 00000000..de2bca89 --- /dev/null +++ b/test/fixtures/vehicle-purchase/exemplar/vehicle-purchase.spec.js @@ -0,0 +1,74 @@ +import { + needsLicense, + chooseVehicle, + calculateResellPrice, +} from './vehicle-purchase'; + +describe('vehicle purchase', () => { + describe('needsLicense', () => { + test('requires a license for a car', () => { + expect(needsLicense('car')).toBe(true); + }); + + test('requires a license for a truck', () => { + expect(needsLicense('truck')).toBe(true); + }); + + test('does not require a license for a bike', () => { + expect(needsLicense('bike')).toBe(false); + }); + + test('does not require a license for a stroller', () => { + expect(needsLicense('stroller')).toBe(false); + }); + + test('does not require a license for an e-scooter', () => { + expect(needsLicense('e-scooter')).toBe(false); + }); + }); + + describe('chooseVehicle', () => { + const rest = ' is clearly the better choice.'; + + test('correctly recommends the first option', () => { + expect(chooseVehicle('Bugatti Veyron', 'Ford Pinto')).toBe( + 'Bugatti Veyron' + rest + ); + expect(chooseVehicle('Chery EQ', 'Kia Niro Elektro')).toBe( + 'Chery EQ' + rest + ); + }); + + test('correctly recommends the second option', () => { + expect(chooseVehicle('Ford Pinto', 'Bugatti Veyron')).toBe( + 'Bugatti Veyron' + rest + ); + expect(chooseVehicle('2020 Gazelle Medeo', '2018 Bergamont City')).toBe( + '2018 Bergamont City' + rest + ); + }); + }); + + describe('calculateResellPrice', () => { + test('price is reduced to 80% for age below 3', () => { + expect(calculateResellPrice(40000, 2)).toBe(32000); + expect(calculateResellPrice(40000, 2.5)).toBe(32000); + }); + + test('price is reduced to 50% for age above 10', () => { + expect(calculateResellPrice(40000, 2)).toBe(32000); + }); + + test('price is reduced to 70% for between 3 and 10', () => { + expect(calculateResellPrice(25000, 7)).toBe(17500); + }); + + test('works correctly for threshold age 3', () => { + expect(calculateResellPrice(40000, 3)).toBe(28000); + }); + + test('works correctly for threshold age 10', () => { + expect(calculateResellPrice(25000, 10)).toBe(17500); + }); + }); +});