From 9d3552ec587dc1d29378f8fe200e95efcd6f71c7 Mon Sep 17 00:00:00 2001 From: Derk-Jan Karrenbeld Date: Tue, 20 Apr 2021 00:38:24 +0200 Subject: [PATCH] [V3] Translation Service: fix everything around this exercise #1071 (#1096) --- .eslintignore | 3 + concepts/promises/introduction.md | 2 - concepts/promises/links.json | 7 +- config.json | 2 +- exercises/concept/promises/.docs/hints.md | 28 -- .../concept/promises/.docs/instructions.md | 102 ------- .../concept/promises/.docs/introduction.md | 6 - exercises/concept/promises/.meta/config.json | 11 - exercises/concept/promises/promises.spec.js | 255 ------------------ .../translation-service/.docs/hints.md | 22 ++ .../translation-service/.docs/instructions.md | 124 +++++++++ .../translation-service/.docs/introduction.md | 37 +++ .../concept/translation-service/.eslintignore | 2 + .../.eslintrc | 0 .../.gitignore | 0 .../translation-service/.meta/config.json | 12 + .../.meta/env.d.ts | 0 .../.meta/exemplar.alternative.js | 40 +-- .../.meta/exemplar.js | 40 +-- .../{promises => translation-service}/.npmrc | 0 .../{promises => translation-service}/LICENSE | 0 exercises/concept/translation-service/api.js | 100 +++++++ .../babel.config.js | 0 .../concept/translation-service/errors.js | 27 ++ .../global.d.ts | 0 .../package.json | 4 +- .../service.js} | 0 .../translation-service/service.spec.js | 205 ++++++++++++++ scripts/helpers.js | 53 +++- scripts/name-check | 4 +- scripts/name-uniq | 2 +- scripts/pr-check | 12 +- 32 files changed, 644 insertions(+), 456 deletions(-) delete mode 100644 exercises/concept/promises/.docs/hints.md delete mode 100644 exercises/concept/promises/.docs/instructions.md delete mode 100644 exercises/concept/promises/.docs/introduction.md delete mode 100644 exercises/concept/promises/.meta/config.json delete mode 100644 exercises/concept/promises/promises.spec.js create mode 100644 exercises/concept/translation-service/.docs/hints.md create mode 100644 exercises/concept/translation-service/.docs/instructions.md create mode 100644 exercises/concept/translation-service/.docs/introduction.md create mode 100644 exercises/concept/translation-service/.eslintignore rename exercises/concept/{promises => translation-service}/.eslintrc (100%) rename exercises/concept/{promises => translation-service}/.gitignore (100%) create mode 100644 exercises/concept/translation-service/.meta/config.json rename exercises/concept/{promises => translation-service}/.meta/env.d.ts (100%) rename exercises/concept/{promises => translation-service}/.meta/exemplar.alternative.js (100%) rename exercises/concept/{promises => translation-service}/.meta/exemplar.js (100%) rename exercises/concept/{promises => translation-service}/.npmrc (100%) rename exercises/concept/{promises => translation-service}/LICENSE (100%) create mode 100644 exercises/concept/translation-service/api.js rename exercises/concept/{promises => translation-service}/babel.config.js (100%) create mode 100644 exercises/concept/translation-service/errors.js rename exercises/concept/{promises => translation-service}/global.d.ts (100%) rename exercises/concept/{promises => translation-service}/package.json (88%) rename exercises/concept/{promises/promises.js => translation-service/service.js} (100%) create mode 100644 exercises/concept/translation-service/service.spec.js diff --git a/.eslintignore b/.eslintignore index d2b2f271bd..6d8a44d087 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,5 @@ !.meta + +/exercises/**/global.d.ts +/exercises/**/env.d.ts diff --git a/concepts/promises/introduction.md b/concepts/promises/introduction.md index fe99210414..3020cc34b9 100644 --- a/concepts/promises/introduction.md +++ b/concepts/promises/introduction.md @@ -2,5 +2,3 @@ The `Promise` object represents the eventual completion (or failure) of an asynchronous operation, and its resulting value. - -https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises diff --git a/concepts/promises/links.json b/concepts/promises/links.json index fe51488c70..dfe48e7565 100644 --- a/concepts/promises/links.json +++ b/concepts/promises/links.json @@ -1 +1,6 @@ -[] +[ + { + "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises", + "description": "MDN: Using promises" + } +] diff --git a/config.json b/config.json index 475aa2fe90..4e48887aff 100644 --- a/config.json +++ b/config.json @@ -157,7 +157,7 @@ "status": "beta" }, { - "slug": "promises", + "slug": "translation-service", "name": "Translation Service", "uuid": "4a967656-8615-474e-a009-5c0b09f4386f", "concepts": ["promises"], diff --git a/exercises/concept/promises/.docs/hints.md b/exercises/concept/promises/.docs/hints.md deleted file mode 100644 index 02a1171c3c..0000000000 --- a/exercises/concept/promises/.docs/hints.md +++ /dev/null @@ -1,28 +0,0 @@ -# Hints - -## 1. Fetch a translation, ignoring the quality - -- Promises are chainable, for example by using `.then`. -- Promises will forward any value. That means that if a promise resolves, it - will forward that value until it reaches the end of the chain or a `.then`, - which receives the value as its argument. -- Promises will forward any error. That means that if a promise rejects, it - will forward that rejection until it reaches the end of the chain or a - `.catch`, which receives the value as its argument and can handle it. - -## 2. Fetch a batch of translations, all-or-nothing - -- In order to return a promise with an error, create a `Promise` that is - `rejected` from the start. -- There is a helper method on `Promise` which waits for an array of promises to - resolve, before it resolves itself. - -## 3. Request a translation, retrying at most 2 times - -- Convert the `callback` to a promise using the `new Promise` constructor. - -## 4. Fetch a translation, inspect the quality, or request it - -- Instead of nesting `.then` and/or `.catch`, `.then` takes a second argument - which catches everything _before_ (earlier in the chain), ignoring errors - in the first argument of `.then`. diff --git a/exercises/concept/promises/.docs/instructions.md b/exercises/concept/promises/.docs/instructions.md deleted file mode 100644 index 614325c358..0000000000 --- a/exercises/concept/promises/.docs/instructions.md +++ /dev/null @@ -1,102 +0,0 @@ -# Instructions - -In this exercise you'll be providing a `TranslationService` where paid members -have some quality assurance. - -You have found a magical translation API that is able to fulfill any -translation _request_ in a reasonable amount of time, and you -want to capitalize on this. - -The magical API has a very minimal interface: - -## Fetching a translation - -`api.fetch(text)` fetches the translation of `text`, returning two values: - -- `translation`: the actual translation -- `quality`: the quality expressed as a number - -If there is no translation available (because it has not been requested yet), -the API throws an error. This also happens if a piece of text is untranslatable. - -## Requesting a translation - -`api.request(text, callback)` requests the translation of `text`, calling the -`callback` once it's ready, without a value. - -The `request` API is unstable, which means that sometimes the API will call the -`callback` with an error. If that happens, it is okay to re-request. - -## ⚠ Warning! ⚠ - -Because of some previous users being lazy when programming, always requesting a -translation, without even checking if the text was already translated, the API -returns an error if the text has already been translated ánd blocks all access -completely, forever. - -## Tasks - -## 1. Fetch a translation, ignoring the quality - -Implement a function to fetch a translation, ignoring the quality, and -forwarding any errors thrown by the API: - -```javascript -service.free('jIyaj'); -// => Promise<...> resolves "I understand." - -service.free("jIyajbe'"); -// => Promise<...> rejects Error("Not yet translated") -``` - -- Returns the translation if it can be retrieved, regardless its quality -- Forwards any error from the translation API - -## 2. Fetch a batch of translations, all-or-nothing - -Implement a function that batch translates the given texts using the free -service, returning all the translations, or a single error. - -```javascript -service.batch(['jIyaj', "majQa'"]); -// => Promise<...> resolves ["I understand.", "Well done!"] - -service.batch(['jIyaj', "jIyajbe'"]); -// => Promise<...> rejects new Error("Not yet translated") - -service.batch([]); -// => Promise<...> rejects BatchIsEmpty() -``` - -- Resolves with all the translations (in the same order), if they are all available -- Rejects with the first error that is encountered -- Rejects with a `BatchIsEmpty` error if no texts are given - -## 3. Request a translation, retrying at most 2 times - -Implement a function that requests a translation, with automatic retries, up to a total of 3 calls for the same request. - -```javascript -service.request("jIyajbe'"); -// => Promise<...> resolves (with nothing), can now be retrieved using the fetch API -``` - -## 4. Fetch a translation, inspect the quality, or request it - -Implement the function for premium users which fetch a translation, request it -if it's not available, and only return it if it meets a certain threshold. - -```javascript -service.premium("jIyajbe'", 100); -// => Promise<...> resolves "I don't understand." - -service.premium("'arlogh Qoylu'pu'?", 100); -// => Promise<...> rejects QualityThresholdNotMet() - -service.premium("'arlogh Qoylu'pu'?", 40); -// => Promise<...> resolves "What time is it?" -``` - -## N.B. - -The correct translation of `'arlogh Qoylu'pu'?` is **How many times has it been heard?**. diff --git a/exercises/concept/promises/.docs/introduction.md b/exercises/concept/promises/.docs/introduction.md deleted file mode 100644 index fe99210414..0000000000 --- a/exercises/concept/promises/.docs/introduction.md +++ /dev/null @@ -1,6 +0,0 @@ -# Introduction - -The `Promise` object represents the eventual completion (or failure) of an -asynchronous operation, and its resulting value. - -https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises diff --git a/exercises/concept/promises/.meta/config.json b/exercises/concept/promises/.meta/config.json deleted file mode 100644 index 631cddab2c..0000000000 --- a/exercises/concept/promises/.meta/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "blurb": "TODO: add blurb for promises exercise", - "authors": ["SleeplessByte"], - "contributors": [], - "files": { - "solution": ["promises.js"], - "test": ["promises.spec.js"], - "exemplar": [".meta/exemplar.js"] - }, - "forked_from": [] -} diff --git a/exercises/concept/promises/promises.spec.js b/exercises/concept/promises/promises.spec.js deleted file mode 100644 index ab41a2b4fc..0000000000 --- a/exercises/concept/promises/promises.spec.js +++ /dev/null @@ -1,255 +0,0 @@ -// @ts-check - -import { - TranslationService, - QualityThresholdNotMet, - BatchIsEmpty, -} from './promises'; - -class NotAvailable extends Error { - constructor(text) { - super( - ` -The requested text "${text}" has not been translated yet. - `.trim() - ); - } -} - -class AbusiveClientError extends Error { - constructor() { - super( - ` -Your client has been rejected because of abusive behaviour. - -naDevvo’ yIghoS! - `.trim() - ); - } -} - -class Untranslatable extends Error { - constructor() { - super('jIyajbe’'); - } -} - -const mutex = { current: false }; - -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)}`); -} - -/** - * @typedef {{ translation: string, quality: number }} Translation - * @typedef {Record>} TranslatableValues - * - */ -class ExternalApi { - /** - * @param {Readonly} values - */ - constructor(values) { - /** @type {TranslatableValues} */ - this.values = JSON.parse(JSON.stringify(values)); - } - - /** - * @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()); - } -} - -describe('promises', () => { - describe('free service', () => { - const api = new ExternalApi({ - jIyaj: [{ translation: 'I understand', quality: 100 }], - 'jIyajbe’': [null, { translation: "I don't understand", quality: 100 }], - }); - - const service = new TranslationService(api); - - test("service.free('jIyaj'): it can fetch a translation", () => - expect(service.free('jIyaj')).resolves.toBe('I understand')); - - xtest("service.free('jIyajbe’): it forwards errors from the API", () => - // Tests that the error returned is unaltered - expect(service.free('jIyajbe’')).rejects.toThrow(NotAvailable)); - - xtest("service.free('majQa’'): it forwards errors from the API", () => - // Tests that the error returned is unaltered - expect(service.free('majQa’')).rejects.toThrow(Untranslatable)); - }); - - describe('batch service', () => { - const api = new ExternalApi({ - jIyaj: [{ translation: 'I understand', quality: 100 }], - 'majQa’': [{ translation: 'Well done!', quality: 100 }], - 'jIyajbe’': [], // will be marked as not yet translated, but is not - // translatable, ever - }); - - const service = new TranslationService(api); - - xtest("service.batch(['jIyaj', 'majQa’'])", () => - expect(service.batch(['jIyaj', 'majQa’'])).resolves.toEqual([ - 'I understand', - 'Well done!', - ])); - - xtest("service.batch(['majQa’', 'jIyaj']): it maintains the order of input", () => - // Tests that the order is maintained - expect(service.batch(['majQa’', 'jIyaj'])).resolves.toEqual([ - 'Well done!', - 'I understand', - ])); - - xtest("service.batch(['jIyaj']): it works with just one element", () => - // Tests that single elements work - expect(service.batch(['jIyaj'])).resolves.toEqual(['I understand'])); - - xtest("service.batch(['jIyaj', 'jIyajbe’', 'majQa’']): it's all or nothing", () => - // Tests that any error rejects the whole thing - expect(service.batch(['jIyaj', 'jIyajbe’', 'majQa’'])).rejects.toThrow( - NotAvailable - )); - - xtest('service.batch([]): it throws on an empty input', () => - expect(service.batch([])).rejects.toThrow(BatchIsEmpty)); - }); - - describe('request service', () => { - const api = new ExternalApi({ - 'majQa’': [null, { translation: 'Well done!', quality: 100 }], - 'jIyajbe’': [ - null, - null, - null, - { translation: "I don't understand", quality: 100 }, - ], - 'ghobe’': [null, null, null, null, { translation: 'No!', quality: 100 }], - }); - - const service = new TranslationService(api); - - xtest("service.request('majQa’')", () => - expect(service.request('majQa’')).resolves.toBe(undefined)); - - // Tests that it eventually ends (resolves or rejects) - xtest("service.request('foo'): it eventually settles", () => - expect(service.request('foo')).rejects.toThrow(Untranslatable)); - - // Tests it tries 3 times - xtest("service.request('jIyajbe’'): it requests up to three times (retries twice)", () => - expect(service.request('jIyajbe’')).resolves.toBe(undefined)); - - // Tests it _only_ tries 3 times - xtest("service.request('ghobe’'): it requests at most three times", () => - expect(service.request('ghobe’')).rejects.toThrow(Error)); - }); - - describe('premium service', () => { - const api = new ExternalApi({ - 'majQa’': [{ translation: 'Well done', quality: 90 }], - 'jIyajbe’': [null, { translation: "I don't understand", quality: 100 }], - 'ghobe’': [null, null, null, null, { translation: 'No!', quality: 100 }], - '‘arlogh Qoylu’pu’?': [ - null, - { translation: 'What time is it?', quality: 75 }, - ], - }); - - const service = new TranslationService(api); - - // Test it can just return a fetched value - xtest("service.premium('majQa’', 90): it returns a translation", () => - expect(service.premium('majQa’', 90)).resolves.toBe('Well done')); - - // Test it checks the quality - xtest("service.premium('majQa’', 100): it ensures the quality", () => - expect(service.premium('majQa’', 100)).rejects.toThrow( - QualityThresholdNotMet - )); - - // Test it requests then, fetches - xtest("service.premium('jIyajbe’', 100): it requests, then fetches", () => - expect(service.premium('jIyajbe’', 100)).resolves.toBe( - "I don't understand" - )); - - // Tests that it eventually ends (resolves or rejects) - xtest("service.premium('foo', 0): it eventually settles", () => - expect(service.premium('foo', 0)).rejects.toThrow(Untranslatable)); - - // Test it only retries 2 times - xtest("service.premium('ghobe’', 100): it requests at most three times (two retries)", () => - expect(service.premium('ghobe’', 100)).rejects.toThrow(Error)); - - // Test it still checks the quality if its fetched after a request - xtest("service.premium('‘arlogh Qoylu’pu’?', 40): it always ensures the quality", () => - expect(service.premium('‘arlogh Qoylu’pu’?', 40)).resolves.toBe( - 'What time is it?' - )); - - // Test it checks the quality - xtest("service.premium('‘arlogh Qoylu’pu’?', 100)", () => - expect(service.premium('‘arlogh Qoylu’pu’?', 100)).rejects.toThrow( - QualityThresholdNotMet - )); - }); -}); diff --git a/exercises/concept/translation-service/.docs/hints.md b/exercises/concept/translation-service/.docs/hints.md new file mode 100644 index 0000000000..08509d0496 --- /dev/null +++ b/exercises/concept/translation-service/.docs/hints.md @@ -0,0 +1,22 @@ +# Hints + +## 1. Fetch a translation, ignoring the quality + +- Promises are chainable, for example by using `.then`. +- Promises will forward any value. + That means that if a promise resolves, it will forward that value until it reaches the end of the chain or a `.then`, which receives the value as its argument. +- Promises will forward any error. + That means that if a promise rejects, it will forward that rejection until it reaches the end of the chain or a `.catch`, which receives the value as its argument and can handle it. + +## 2. Fetch a batch of translations, all-or-nothing + +- In order to return a promise with an error, create a `Promise` that is `rejected` from the start. +- There is a helper method on `Promise` which waits for an array of promises to resolve, before it resolves itself. + +## 3. Request a translation, retrying at most 2 times + +- Convert the `callback` to a promise using the `new Promise` constructor. + +## 4. Fetch a translation, inspect the quality, or request it + +- Instead of nesting `.then` and/or `.catch`, `.then` takes a second argument which catches everything _before_ (earlier in the chain), ignoring errors in the first argument of `.then`. diff --git a/exercises/concept/translation-service/.docs/instructions.md b/exercises/concept/translation-service/.docs/instructions.md new file mode 100644 index 0000000000..8352e12ed8 --- /dev/null +++ b/exercises/concept/translation-service/.docs/instructions.md @@ -0,0 +1,124 @@ +# Instructions + +In this exercise you'll be providing a `TranslationService` where paid members have some quality assurance. + +You have found an out-of-space translation API that is able to fulfill any translation _request_ in a reasonable amount of time, and you want to capitalize on this. + +## The API interface + +The API has a very minimal interface: + +### Fetching a translation + +`api.fetch(text)` fetches the translation of `text`, returning two values: + +- `translation`: the actual translation +- `quality`: the quality expressed as a number + +If there is no translation available (because it has not been requested yet, see below), the API throws a `NotAvailable` error. +An `Untranslatable` error is thrown if a piece of text is untranslatable. + +```javascript +api.fetch('jIyaj'); +// => Promise({ resolved: 'I understand' }) +``` + +### Requesting a translation + +Some translations are known in the future. +The API knows about these. +That's the difference between `NotAvailable` (will be available, but must be requested) and `Untranslatable` (will never be available). + +`api.request(text, callback)` requests the translation of `text`, calling the `callback` once it's ready, without a value, only indicating that it is now available. + +> This API is _unstable_, which means that sometimes the API will fail and call the `callback` with an error. +> If that happens, it is okay to re-request. + +```javascript +api.request('majQa’'); +// => Promise({ resolved: undefined }) +``` + +### ⚠ Warning! ⚠ + +```exercism/caution +The API works its magic by teleporting in the various translators when a `request` comes in. +This is a very costly action, so it shouldn't be called when a translation *is* available. +Unfortunately not everyone reads the manual, so there is a system in place to kick-out bad actors. + +If a `api.request` is called for `text` is available, the API throws an `AbusiveClientError` for this call, **and every call after that**. +Ensure that you *never* request a translation if something has already been translated. +``` + +## 1. Fetch a translation, ignoring the quality + +Implement a function `free(text)` to fetch a translation, ignoring the quality, and forwarding any errors thrown by the API: + +- Returns the translation if it can be retrieved, regardless its quality +- Forwards any error from the translation API + +```javascript +service.free('jIyaj'); +// => Promise<...> resolves "I understand." + +service.free("jIyajbe'"); +// => Promise<...> rejects Error("Not yet translated") +``` + +## 2. Fetch a batch of translations, all-or-nothing + +Implement a function `batch([text, text, ...])` that translates the given texts using the free service, returning all the translations, or a single error. + +- Resolves with all the translations (in the same order), if they are all available +- Rejects with the first error that is encountered +- Rejects with a `BatchIsEmpty` error if no texts are given + +```javascript +service.batch(['jIyaj', "majQa'"]); +// => Promise<...> resolves ["I understand.", "Well done!"] + +service.batch(['jIyaj', "jIyajbe'"]); +// => Promise<...> rejects new Error("Not yet translated") + +service.batch([]); +// => Promise<...> rejects BatchIsEmpty() +``` + +## 3. Request a translation, retrying at most 2 times + +Implement a function `request(text)` that _requests_ a translation, with automatic retries, up to a total of **3 calls** for the same request. + +- If `api.request` does not return an error, resolve with `undefined` +- If `api.request` returns an error, retry at most two times +- If you're out of retires, reject with the last error received + +```javascript +service.request("jIyajbe'"); +// => Promise<...> resolves (with nothing), can now be retrieved using the fetch API +``` + +## 4. Fetch a translation, inspect the quality, or request it + +Implement the function `premium(text, quality)` for premium users, which fetches a translation, request it if it's not available, and only returns it if it meets a certain threshold. + +- If `api.fetch` resolves, check the quality before resolving +- If `api.fetch` rejects with `NotAvailable`, _request_ the translation instead +- If `api.fetch` rejects with `Untranslatable`, forward the error +- If _requesting_ rejects, forward the error + +```javascript +service.premium("jIyajbe'", 100); +// => Promise<...> resolves "I don't understand." + +service.premium("'arlogh Qoylu'pu'?", 100); +// => Promise<...> rejects QualityThresholdNotMet() + +service.premium("'arlogh Qoylu'pu'?", 40); +// => Promise<...> resolves "What time is it?" +``` + +## N.B. + +```exercism/note +The correct translation of `'arlogh Qoylu'pu'?` is **How many times has it been heard?**. +``` diff --git a/exercises/concept/translation-service/.docs/introduction.md b/exercises/concept/translation-service/.docs/introduction.md new file mode 100644 index 0000000000..86f681831d --- /dev/null +++ b/exercises/concept/translation-service/.docs/introduction.md @@ -0,0 +1,37 @@ +# Introduction + +The [`Promise` object][mdn-promise] represents the eventual completion (or failure) of an asynchronous operation, and its resulting value. + +> Essentially, a promise is a returned object to which you attach callbacks, instead of passing callbacks into a function. + +A `Promise` has three states: + +1. unresolved +2. resolved +3. rejected + +When a `Promise` is _settled_, it means that it has either _resolved_ or _rejected_. + +## Guarantees + +Unlike old-fashioned _passed-in_ callbacks, a promise comes with some guarantees, including, but not limited to: + +- A `Promise` can only change its state _once_, which means that a resolved promise can never become rejected. +- Callbacks added with [`.then`][mdn-promise-then] are called even if the promise has already settled. +- Multiple callback may be added using [`.then`][mdn-promise-then], and those callbacks will be invoked in the order as they were inserted. + +## Chaining + +> TODO: `.then`, `.catch` + +## Constructing + +> TODO: `Promise.resolve` `Promise.reject` `new Promise(resolve, reject)` + +## More about promises + +See [this guide][mdn-guide-promise] for more about using promises. + +[mdn-promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise +[mdn-promise-then]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then +[mdn-guide-promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises diff --git a/exercises/concept/translation-service/.eslintignore b/exercises/concept/translation-service/.eslintignore new file mode 100644 index 0000000000..925cb98e75 --- /dev/null +++ b/exercises/concept/translation-service/.eslintignore @@ -0,0 +1,2 @@ +/global.d.ts +/env.d.ts diff --git a/exercises/concept/promises/.eslintrc b/exercises/concept/translation-service/.eslintrc similarity index 100% rename from exercises/concept/promises/.eslintrc rename to exercises/concept/translation-service/.eslintrc diff --git a/exercises/concept/promises/.gitignore b/exercises/concept/translation-service/.gitignore similarity index 100% rename from exercises/concept/promises/.gitignore rename to exercises/concept/translation-service/.gitignore diff --git a/exercises/concept/translation-service/.meta/config.json b/exercises/concept/translation-service/.meta/config.json new file mode 100644 index 0000000000..c6e4a724b0 --- /dev/null +++ b/exercises/concept/translation-service/.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/exercises/concept/promises/.meta/env.d.ts b/exercises/concept/translation-service/.meta/env.d.ts similarity index 100% rename from exercises/concept/promises/.meta/env.d.ts rename to exercises/concept/translation-service/.meta/env.d.ts diff --git a/exercises/concept/promises/.meta/exemplar.alternative.js b/exercises/concept/translation-service/.meta/exemplar.alternative.js similarity index 100% rename from exercises/concept/promises/.meta/exemplar.alternative.js rename to exercises/concept/translation-service/.meta/exemplar.alternative.js index b1304d4958..066d280767 100644 --- a/exercises/concept/promises/.meta/exemplar.alternative.js +++ b/exercises/concept/translation-service/.meta/exemplar.alternative.js @@ -1,25 +1,5 @@ // @ts-check -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() - ); - } -} - export class TranslationService { /** * @@ -113,3 +93,23 @@ export class TranslationService { } } } + +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/exercises/concept/promises/.meta/exemplar.js b/exercises/concept/translation-service/.meta/exemplar.js similarity index 100% rename from exercises/concept/promises/.meta/exemplar.js rename to exercises/concept/translation-service/.meta/exemplar.js index 41383972ea..fb7a258405 100644 --- a/exercises/concept/promises/.meta/exemplar.js +++ b/exercises/concept/translation-service/.meta/exemplar.js @@ -1,25 +1,5 @@ // @ts-check -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() - ); - } -} - export class TranslationService { /** * @@ -106,3 +86,23 @@ export class TranslationService { }); } } + +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/exercises/concept/promises/.npmrc b/exercises/concept/translation-service/.npmrc similarity index 100% rename from exercises/concept/promises/.npmrc rename to exercises/concept/translation-service/.npmrc diff --git a/exercises/concept/promises/LICENSE b/exercises/concept/translation-service/LICENSE similarity index 100% rename from exercises/concept/promises/LICENSE rename to exercises/concept/translation-service/LICENSE diff --git a/exercises/concept/translation-service/api.js b/exercises/concept/translation-service/api.js new file mode 100644 index 0000000000..de99e74704 --- /dev/null +++ b/exercises/concept/translation-service/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/exercises/concept/promises/babel.config.js b/exercises/concept/translation-service/babel.config.js similarity index 100% rename from exercises/concept/promises/babel.config.js rename to exercises/concept/translation-service/babel.config.js diff --git a/exercises/concept/translation-service/errors.js b/exercises/concept/translation-service/errors.js new file mode 100644 index 0000000000..7c98b921a4 --- /dev/null +++ b/exercises/concept/translation-service/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/exercises/concept/promises/global.d.ts b/exercises/concept/translation-service/global.d.ts similarity index 100% rename from exercises/concept/promises/global.d.ts rename to exercises/concept/translation-service/global.d.ts diff --git a/exercises/concept/promises/package.json b/exercises/concept/translation-service/package.json similarity index 88% rename from exercises/concept/promises/package.json rename to exercises/concept/translation-service/package.json index cc86c2f8bd..b5e21a9fa8 100644 --- a/exercises/concept/promises/package.json +++ b/exercises/concept/translation-service/package.json @@ -1,5 +1,5 @@ { - "name": "@exercism/javascript-promises", + "name": "@exercism/javascript-translation-service", "description": "Exercism concept exercise on promises", "author": "Derk-Jan Karrenbeld ", "private": true, @@ -7,7 +7,7 @@ "repository": { "type": "git", "url": "https://github.com/exercism/javascript", - "directory": "languages/javascript/exercises/concept/promises" + "directory": "exercises/concept/translation-service" }, "devDependencies": { "@babel/cli": "^7.13.14", diff --git a/exercises/concept/promises/promises.js b/exercises/concept/translation-service/service.js similarity index 100% rename from exercises/concept/promises/promises.js rename to exercises/concept/translation-service/service.js diff --git a/exercises/concept/translation-service/service.spec.js b/exercises/concept/translation-service/service.spec.js new file mode 100644 index 0000000000..51f64101cd --- /dev/null +++ b/exercises/concept/translation-service/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/scripts/helpers.js b/scripts/helpers.js index 45a7fdf0ae..d371441667 100644 --- a/scripts/helpers.js +++ b/scripts/helpers.js @@ -277,7 +277,23 @@ export function prepare(assignment) { files.test.forEach((specFileName) => { const specFile = path.join('exercises', assignment, specFileName); - const specFileDestination = path.join('tmp_exercises', specFileName); + + // Skip file if it doesn't exist + if (!shell.test('-f', specFile)) { + if (specFileName !== 'custom.spec.js') { + console.warn( + `Skipped copying test file for ${assignment}: ${specFileName} because it doesn't exist` + ); + } + + return; + } + + const specFileDestination = path.join( + 'tmp_exercises', + assignment, + specFileName + ); shell.mkdir('-p', path.dirname(specFileDestination)); shell.cp(specFile, specFileDestination); @@ -297,23 +313,28 @@ export function prepare(assignment) { .to(specFileDestination); }); - shell.mkdir('-p', path.join('tmp_exercises', 'lib')); + shell.mkdir('-p', path.join('tmp_exercises', assignment, 'lib')); exampleFiles.forEach((exampleFileName, i) => { const exampleFile = path.join('exercises', assignment, exampleFileName); const exampleFileDestination = path.join( 'tmp_exercises', + assignment, files.solution[i] ); shell.sed("from '../", "from './", exampleFile).to(exampleFileDestination); }); + // If there are more solution files than example or exemplar files, copy over + // the remaining ones. This allows us to overwrite solution files by providing + // them in exemplar, but keep them if (files.solution.length > exampleFiles.length) { files.solution.slice(exampleFiles.length).forEach((extraLibFileName) => { const solutionFile = path.join('exercises', assignment, extraLibFileName); const solutionFileDestination = path.join( 'tmp_exercises', + assignment, extraLibFileName ); @@ -321,16 +342,38 @@ export function prepare(assignment) { }); } + // Copy new-style editor files + if ('editor' in files) { + files.editor.forEach((readonlyFileName) => { + const readonlyFile = path.join('exercises', assignment, readonlyFileName); + const readonlyFileDestination = path.join( + 'tmp_exercises', + assignment, + readonlyFileName + ); + + shell.cp(readonlyFile, readonlyFileDestination); + }); + } + + // Copy legacy lib files const libDir = path.join('exercises', assignment, 'lib'); if (shell.test('-d', libDir)) { - shell.cp(path.join(libDir, '*.js'), path.join('tmp_exercises', 'lib')); + shell.cp( + path.join(libDir, '*.js'), + path.join('tmp_exercises', assignment, 'lib') + ); } - shell.mkdir('-p', path.join('tmp_exercises', 'data')); + // Copy legacy data files + shell.mkdir('-p', path.join('tmp_exercises', assignment, 'data')); const dataDir = path.join('exercises', assignment, 'data'); if (shell.test('-d', dataDir)) { - shell.cp(path.join(dataDir, '*'), path.join('tmp_exercises', 'data')); + shell.cp( + path.join(dataDir, '*'), + path.join('tmp_exercises', assignment, 'data') + ); } } diff --git a/scripts/name-check b/scripts/name-check index b14df76b3e..09c490f03e 100755 --- a/scripts/name-check +++ b/scripts/name-check @@ -30,7 +30,7 @@ packageFiles.forEach((filePath) => { const file = JSON.parse(shell.cat(filePath).toString()); const givenName = file['name']; - const exerciseName = filePath.split('/')[2]; + const exerciseName = filePath.split(/[/\\]/g)[2]; const expectedName = `@exercism/javascript-${exerciseName}`; if (givenName === expectedName) { @@ -52,3 +52,5 @@ packageFiles.forEach((filePath) => { shell.exit(1); } }); + +shell.exit(0); diff --git a/scripts/name-uniq b/scripts/name-uniq index ef8a4c4eeb..4a9dc92187 100755 --- a/scripts/name-uniq +++ b/scripts/name-uniq @@ -28,4 +28,4 @@ if (duplicates.length !== 0) { } shell.echo('[Success] All package names are unique.'); -shell.exit(1); +shell.exit(0); diff --git a/scripts/pr-check b/scripts/pr-check index 948018adb0..d35796d49f 100755 --- a/scripts/pr-check +++ b/scripts/pr-check @@ -93,6 +93,9 @@ if (!envIsThruthy('SKIP_INTEGRITY', false)) { ).code; if (checkResult !== 0) { + shell.echo( + `scripts/checksum returned a non-zero exit code: ${checkResult}` + ); shell.exit(checkResult); } @@ -101,6 +104,9 @@ if (!envIsThruthy('SKIP_INTEGRITY', false)) { ).code; if (nameCheckResult !== 0) { + shell.echo( + `scripts/name-check returned a non-zero exit code: ${nameCheckResult}` + ); shell.exit(nameCheckResult); } }); @@ -110,6 +116,9 @@ if (!envIsThruthy('SKIP_INTEGRITY', false)) { ).code; if (nameUniqResult !== 0) { + shell.echo( + `scripts/name-uniq returned a non-zero exit code: ${nameUniqResult}` + ); shell.exit(nameUniqResult); } } @@ -135,6 +144,7 @@ shell.env['CLEANUP'] = true; const checkResult = shell.exec(`npx babel-node ${path.join('scripts', 'lint')}`) .code; -if (checkResult != 0) { +if (checkResult !== 0) { + shell.echo(`scripts/lint returned a non-zero exit code: ${checkResult}`); shell.exit(checkResult); }