diff --git a/js/tests/personal-shopper_test.mjs b/js/tests/personal-shopper_test.mjs new file mode 100644 index 00000000..a6da757c --- /dev/null +++ b/js/tests/personal-shopper_test.mjs @@ -0,0 +1,375 @@ +import * as cp from 'child_process' +import fs from 'fs/promises' +import { join, resolve, isAbsolute } from 'path' +import { tmpdir } from 'os' +import { promisify } from 'util' +const mkdir = fs.mkdir +const rmdir = fs.rmdir +const readFile = fs.readFile + +const exec = promisify(cp.exec) + +export const tests = [] +const name = 'personal-shopper' +const getRandomElem = () => + Math.random() + .toString(36) + .substring(7) +export const setup = async () => { + const dir = tmpdir() + const elems = {} + const randomElem = getRandomElem() + const anotherElem = getRandomElem() + const commands = ['add', 'rm', 'delete', 'create', 'ls', 'help'] + // check if already exists and rm + const exists = await fs + .stat(`${dir}/${name}`) + .catch((err) => (err.code === 'ENOENT' ? 'file not found: good.' : err)) + if (Object.keys(exists).length) { + await rmdir(`${dir}/${name}`, { recursive: true }) + } + + await mkdir(`${dir}/${name}`) + + return { tmpPath: `${dir}/${name}`, elems, commands, randomElem, anotherElem } +} +const assistInShopping = async ({ + file, + keyword, + elem, + number, + ctx, + path, + eq, +}) => { + // check if file exists before testing create - if exists, rm it + if (keyword === 'create') { + const out = await fs + .stat(`${ctx.tmpPath}/${file}`) + .catch((err) => + err.code === 'ENOENT' ? 'output file not found: normal' : err, + ) + if (out.birthtime) await fs.rm(`${ctx.tmpPath}/${file}`) + } + + const scriptPath = join(resolve(), path) + const { stdout, stderr } = await exec( + `node ${scriptPath} ${file} ${keyword} ${elem || ''} ${number || ''}`, + { + cwd: ctx.tmpPath, + }, + ) + const out = await readFile(`${ctx.tmpPath}/${file}`, 'utf8').catch((err) => + err.code === 'ENOENT' ? 'output file not found' : err, + ) + + if (keyword === 'create') return eq(out, '{}') + + if (keyword === 'add' || keyword === 'rm') { + if (!elem) return eq(stderr.trim(), 'No elem specified.') + if (number && isNaN(Number(number)) && keyword === 'rm') { + return eq(stderr.trim(), 'Unexpected request: nothing has been removed.') + } + const content = JSON.parse(out) + + const addValue = !number || isNaN(Number(number)) ? 1 : number + const rmValue = !number ? ctx.elems[elem] : number + const newNumber = + keyword === 'add' + ? (ctx.elems[elem] || 0) + addValue + : (ctx.elems[elem] || 0) - rmValue + const state = newNumber > 0 ? content[elem] === newNumber : !content[elem] + ctx.elems[elem] = newNumber > 0 ? newNumber : 0 + return state + } + + if (keyword === 'ls') { + const content = JSON.parse(out) + const entries = Object.entries(content) + return entries.length + ? eq(entries.map(([k, v]) => `- ${k} (${v})`).join('\n'), stdout.trim()) + : eq(stdout.trim(), 'Empty list.') + } + + if (keyword === 'help' || !keyword) { + return ctx.commands.every((c) => stdout.includes(c)) + } + + if (keyword === 'delete') return eq(out, 'output file not found') +} + +tests.push(async ({ path, eq, ctx }) => { + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'create', + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // no elem specifiel, should log an error + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // add NaN value, should add 1 + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + number: 'someNaN', + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // add without number, should add 1 + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // add random value, between 5 and 15 + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + number: Math.floor(Math.random() * (15 - 5) + 5), + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // add negative value, bigger than current value - should delete the entry + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + number: -Math.floor(Math.random() * (25 - 16) + 16), + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // add value to prepare next test + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + number: Math.floor(Math.random() * (25 - 16) + 16), + }) +}) +tests.push(async ({ path, eq, ctx }) => { + // add negative exact nm of elem -> 0, should delete the entry + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + number: -ctx.elems[ctx.randomElem], + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // no elem specified, should log an error + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + // no elem, no number + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // rm elem which is not in the list, should do nothing + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + elem: ctx.randomElem, + // no number + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // rm negative value, should add it + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + elem: ctx.randomElem, + number: -Math.floor(Math.random() * (25 - 16) + 16), + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // rm positive value, smaller than current value -> should substract it from current value + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + elem: ctx.randomElem, + number: Math.floor(Math.random() * (10 - 5) + 5), + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // should print the list with the elem as expected inside + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'ls', + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // rm without number, should delete the entry + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + elem: ctx.randomElem, + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // should print an empty list + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'ls', + }) +}) + +// add 2 elems - tests: ls several values, rm more or same value as current +tests.push(async ({ path, eq, ctx }) => { + // add value to prepare next test + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.randomElem, + number: Math.floor(Math.random() * (25 - 16) + 16), + }) +}) +tests.push(async ({ path, eq, ctx }) => { + // add value to prepare next test + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'add', + elem: ctx.anotherElem, + number: ctx.elems[ctx.randomElem], // add same value than randomelem + }) +}) +tests.push(async ({ path, eq, ctx }) => { + // test ls with several elems + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'ls', + }) +}) +tests.push(async ({ path, eq, ctx }) => { + // test where rm exat nm of elem -> 0, delete the entry + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + elem: ctx.randomElem, + number: ctx.elems[ctx.randomElem], + }) +}) +tests.push(async ({ path, eq, ctx }) => { + // test where rm more than nm of elem -> <0, delete the entry + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'rm', + elem: ctx.randomElem, + number: 2000, + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // should print the helper + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'help', + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + // should print the helper + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: '', + }) +}) + +tests.push(async ({ path, eq, ctx }) => { + return assistInShopping({ + path, + eq, + ctx, + file: `${name}.json`, + keyword: 'delete', + }) +}) + +Object.freeze(tests) diff --git a/subjects/personal-shopper/README.md b/subjects/personal-shopper/README.md new file mode 100644 index 00000000..898ab65b --- /dev/null +++ b/subjects/personal-shopper/README.md @@ -0,0 +1,111 @@ +## personal-shopper + +### Instructions + +You know your guests, but don't forget you have to feed them if you want to be considered as a good host. + +Create a `personal-shopper.mjs` script that: +- Takes a file as first argument, for example `shopping-list.json` +- Takes one of these keywords as second argument: + - `create`: create the file + - `delete`: delete the file + - `add`: add a new element to the list in the file. + - This command line must take a third argument which is the name of the new entry in your list. If no third argument is passed, console must print this error: `No elem specified.`. + - This command line could take a fourth argument which is the number of elements you want for this new entry. + - If there is no 4rth argument or the 4rth argument is `NaN`, 1 would be the value by default. + - If the entry already exists, it would add 1 or more to the original value. + - Using a negative number must behave as `rm` command. + - `rm`: remove an element from the list in the file + - This command line must take a third argument which is the name of the entry to remove from the list. + - If no third argument is passed, console must print this error: `No elem specified.`. + - If the entry does not exists, it does nothing. + - This command line could take a fourth argument which is the number of elements you want to delete from this entry. + - If there is no 4rth argument: it remove the entry. + - If the 4rth argument is `NaN`, nothing is removed and console must print this error `Unexpected request: nothing has been removed`. + - If the 4rth argument is a number, it will substract this number from the original value (if the new value is <= 0, it will remove the entry). + - Using a negative number must behave as `add` command. + - `help`: print all the command lines available, with a description of it (specifications in the examples) + - `ls` or no more arguments: print the list in the console. + - Each line is formated like this: `- element (number)` + - If the list is empty, this message should appear in console: `Empty list.`. + +#### Examples + +- `node personal-shopper.mjs shopping-list.json create` would create the file +- `node personal-shopper.mjs shopping-list.json delete` would remove the file + +- `node personal-shopper.mjs shopping-list.json add "tzatziki pot"` would update the content of the file like: +```json +'{ + "tzatziki pot": 1 +}' +``` + +- `node personal-shopper.mjs shopping-list.json add carrots 5` would update the content of the file like: +```json +'{ + "tzatziki pot": 1, + "carrots": 5 +}' +``` + +- `node personal-shopper.mjs shopping-list.json add carrots 2` would update the content of the file like: +```json +'{ + "tzatziki pot": 1, + "carrots": 7 +}' +``` + +- `node personal-shopper.mjs shopping-list.json rm carrots 4` would update the content of the file like: +```json +'{ + "tzatziki pot": 1, + "carrots": 3 +}' +``` + +- `node personal-shopper.mjs shopping-list.json rm carrots` would update the content of the file like: +```json +'{ + "tzatziki pot": 1 +}' +``` + +- `node personal-shopper.mjs shopping-list.json rm carrots -3` would update the content of the file like: +```json +'{ + "tzatziki pot": 1, + "carrots": 3 +}' +``` + +- `node personal-shopper.mjs shopping-list.json add carrots -3` would update the content of the file like: +```json +'{ + "tzatziki pot": 1 +}' +``` + +- `node personal-shopper.mjs shopping-list.json ls` would print the list in your console like this: +``` +- tzatziki pot (1) +``` + +- `node personal-shopper.mjs help` would print a list of all available commands in your console : +``` +Commands: +- create: takes a filename as argument and create it (should have `.json` extension specified) +- delete: takes a filename as argument and delete it + +``` + +### Notions + +- [Node file system: `rm`](https://nodejs.org/docs/latest/api/fs.html#fs_fspromises_rm_path_options) +- [Node file system: `writeFile`](https://nodejs.org/docs/latest/api/fs.html#fs_fspromises_writefile_file_data_options) +- [`JSON.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) +- [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) +- [`isNaN()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/isNaN) +- [`Number()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number) +- [`console.error()`](https://developer.mozilla.org/en-US/docs/Web/API/Console/error)