diff --git a/config.json b/config.json index 930b0edb7..2b986b16a 100644 --- a/config.json +++ b/config.json @@ -577,6 +577,14 @@ "strings" ] }, + { + "slug": "change", + "name": "Change", + "uuid": "341fdb04-d9bc-42ad-92fb-dff927cff6d2", + "practices": [], + "prerequisites": [], + "difficulty": 4 + }, { "slug": "scrabble-score", "name": "Scrabble Score", @@ -1572,7 +1580,12 @@ "practices": [], "prerequisites": [], "difficulty": 4, - "topics": ["arrays", "conditionals", "filtering", "games"] + "topics": [ + "arrays", + "conditionals", + "filtering", + "games" + ] } ] }, diff --git a/exercises/practice/change/.docs/instructions.md b/exercises/practice/change/.docs/instructions.md new file mode 100644 index 000000000..5887f4cb6 --- /dev/null +++ b/exercises/practice/change/.docs/instructions.md @@ -0,0 +1,8 @@ +# Instructions + +Determine the fewest number of coins to give a customer so that the sum of their values equals the correct amount of change. + +## Examples + +- An amount of 15 with available coin values [1, 5, 10, 25, 100] should return one coin of value 5 and one coin of value 10, or [5, 10]. +- An amount of 40 with available coin values [1, 5, 10, 25, 100] should return one coin of value 5, one coin of value 10, and one coin of value 25, or [5, 10, 25]. diff --git a/exercises/practice/change/.docs/introduction.md b/exercises/practice/change/.docs/introduction.md new file mode 100644 index 000000000..b4f8308a1 --- /dev/null +++ b/exercises/practice/change/.docs/introduction.md @@ -0,0 +1,26 @@ +# Introduction + +In the mystical village of Coinholt, you stand behind the counter of your bakery, arranging a fresh batch of pastries. +The door creaks open, and in walks Denara, a skilled merchant with a keen eye for quality goods. +After a quick meal, she slides a shimmering coin across the counter, representing a value of 100 units. + +You smile, taking the coin, and glance at the total cost of the meal: 88 units. +That means you need to return 12 units in change. + +Denara holds out her hand expectantly. +"Just give me the fewest coins," she says with a smile. +"My pouch is already full, and I don't want to risk losing them on the road." + +You know you have a few options. +"We have Lumis (worth 10 units), Viras (worth 5 units), and Zenth (worth 2 units) available for change." + +You quickly calculate the possibilities in your head: + +- one Lumis (1 × 10 units) + one Zenth (1 × 2 units) = 2 coins total +- two Viras (2 × 5 units) + one Zenth (1 × 2 units) = 3 coins total +- six Zenth (6 × 2 units) = 6 coins total + +"The best choice is two coins: one Lumis and one Zenth," you say, handing her the change. + +Denara smiles, clearly impressed. +"As always, you've got it right." diff --git a/exercises/practice/change/.meta/config.json b/exercises/practice/change/.meta/config.json new file mode 100644 index 000000000..19c1d6435 --- /dev/null +++ b/exercises/practice/change/.meta/config.json @@ -0,0 +1,27 @@ +{ + "authors": [ + "BNAndras" + ], + "files": { + "solution": [ + "change.ts" + ], + "test": [ + "change.test.ts" + ], + "example": [ + ".meta/proof.ci.ts" + ] + }, + "blurb": "Correctly determine change to be given using the least number of coins.", + "custom": { + "version.tests.compatibility": "jest-29", + "flag.tests.task-per-describe": false, + "flag.tests.may-run-long": false, + "flag.tests.includes-optional": false, + "flag.tests.jest": true, + "flag.tests.tstyche": false + }, + "source": "Software Craftsmanship - Coin Change Kata", + "source_url": "https://web.archive.org/web/20130115115225/http://craftsmanship.sv.cmu.edu:80/exercises/coin-change-kata" +} diff --git a/exercises/practice/change/.meta/proof.ci.ts b/exercises/practice/change/.meta/proof.ci.ts new file mode 100644 index 000000000..5ffb69c22 --- /dev/null +++ b/exercises/practice/change/.meta/proof.ci.ts @@ -0,0 +1,29 @@ +export const findFewestCoins = (coins: number[], target: number): number[] => { + if (target < 0) throw new Error("target can't be negative") + + if (target === 0) return [] + + const queue = [0] + const visited: Record = { 0: [] } + + while (queue.length > 0) { + const initialBalance = queue.shift()! + + for (const coin of coins) { + const updatedBalance = initialBalance + coin + + if (updatedBalance > target || updatedBalance in visited) continue + + const usedCoins = [...visited[initialBalance], coin] + + if (updatedBalance === target) { + return usedCoins.sort((a, b) => a - b) + } + + visited[updatedBalance] = usedCoins + queue.push(updatedBalance) + } + } + + throw new Error("can't make target with given coins") +} diff --git a/exercises/practice/change/.meta/tests.toml b/exercises/practice/change/.meta/tests.toml new file mode 100644 index 000000000..2d2f44bc2 --- /dev/null +++ b/exercises/practice/change/.meta/tests.toml @@ -0,0 +1,49 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[d0ebd0e1-9d27-4609-a654-df5c0ba1d83a] +description = "change for 1 cent" + +[36887bea-7f92-4a9c-b0cc-c0e886b3ecc8] +description = "single coin change" + +[cef21ccc-0811-4e6e-af44-f011e7eab6c6] +description = "multiple coin change" + +[d60952bc-0c1a-4571-bf0c-41be72690cb3] +description = "change with Lilliputian Coins" + +[408390b9-fafa-4bb9-b608-ffe6036edb6c] +description = "change with Lower Elbonia Coins" + +[7421a4cb-1c48-4bf9-99c7-7f049689132f] +description = "large target values" + +[f79d2e9b-0ae3-4d6a-bb58-dc978b0dba28] +description = "possible change without unit coins available" + +[9a166411-d35d-4f7f-a007-6724ac266178] +description = "another possible change without unit coins available" + +[ce0f80d5-51c3-469d-818c-3e69dbd25f75] +description = "a greedy approach is not optimal" + +[bbbcc154-e9e9-4209-a4db-dd6d81ec26bb] +description = "no coins make 0 change" + +[c8b81d5a-49bd-4b61-af73-8ee5383a2ce1] +description = "error testing for change smaller than the smallest of coins" + +[3c43e3e4-63f9-46ac-9476-a67516e98f68] +description = "error if no combination can add up to target" + +[8fe1f076-9b2d-4f44-89fe-8a6ccd63c8f3] +description = "cannot find negative change values" diff --git a/exercises/practice/change/.vscode/extensions.json b/exercises/practice/change/.vscode/extensions.json new file mode 100644 index 000000000..daaa5ee2e --- /dev/null +++ b/exercises/practice/change/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "arcanis.vscode-zipfs", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} diff --git a/exercises/practice/change/.vscode/settings.json b/exercises/practice/change/.vscode/settings.json new file mode 100644 index 000000000..761fb422a --- /dev/null +++ b/exercises/practice/change/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": ["exercism"], + "search.exclude": { + "**/.yarn": true, + "**/.pnp.*": true + } +} diff --git a/exercises/practice/change/.yarnrc.yml b/exercises/practice/change/.yarnrc.yml new file mode 100644 index 000000000..23e4a6d3d --- /dev/null +++ b/exercises/practice/change/.yarnrc.yml @@ -0,0 +1,3 @@ +compressionLevel: mixed + +enableGlobalCache: true diff --git a/exercises/practice/change/babel.config.cjs b/exercises/practice/change/babel.config.cjs new file mode 100644 index 000000000..164552797 --- /dev/null +++ b/exercises/practice/change/babel.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + // eslint-disable-next-line @typescript-eslint/no-require-imports + presets: [[require('@exercism/babel-preset-typescript'), { corejs: '3.38' }]], + plugins: [], +} diff --git a/exercises/practice/change/change.test.ts b/exercises/practice/change/change.test.ts new file mode 100644 index 000000000..60749bada --- /dev/null +++ b/exercises/practice/change/change.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, xit } from '@jest/globals' +import { findFewestCoins } from './change.ts' + +describe('Change', () => { + it('change for 1 cent', () => { + expect(findFewestCoins([1, 5, 10, 25], 1)).toEqual([1]) + }) + + xit('single coin change', () => { + expect(findFewestCoins([1, 5, 10, 25, 100], 25)).toEqual([25]) + }) + + xit('multiple coin change', () => { + expect(findFewestCoins([1, 5, 10, 25, 100], 15)).toEqual([5, 10]) + }) + + xit('change with Lilliputian Coins', () => { + expect(findFewestCoins([1, 4, 15, 20, 50], 23)).toEqual([4, 4, 15]) + }) + + xit('change with Lower Elbonia Coins', () => { + expect(findFewestCoins([1, 5, 10, 21, 25], 63)).toEqual([21, 21, 21]) + }) + + xit('large target values', () => { + expect(findFewestCoins([1, 2, 5, 10, 20, 50, 100], 999)).toEqual([ + 2, 2, 5, 20, 20, 50, 100, 100, 100, 100, 100, 100, 100, 100, 100, + ]) + }) + + xit('possible change without unit coins available', () => { + expect(findFewestCoins([2, 5, 10, 20, 50], 21)).toEqual([2, 2, 2, 5, 10]) + }) + + xit('another possible change without unit coins available', () => { + expect(findFewestCoins([4, 5], 27)).toEqual([4, 4, 4, 5, 5, 5]) + }) + + xit('a greedy approach is not optimal', () => { + expect(findFewestCoins([1, 10, 11], 20)).toEqual([10, 10]) + }) + + xit('no coins make 0 change', () => { + expect(findFewestCoins([1, 5, 10, 21, 25], 0)).toEqual([]) + }) + + xit('error testing for change smaller than the smallest of coins', () => { + expect(() => findFewestCoins([5, 10], 3)).toThrow( + "can't make target with given coins" + ) + }) + + xit('error if no combination can add up to target', () => { + expect(() => findFewestCoins([5, 10], 94)).toThrow( + "can't make target with given coins" + ) + }) + + xit('cannot find negative change values', () => { + expect(() => findFewestCoins([1, 2, 5], -5)).toThrow( + "target can't be negative" + ) + }) +}) diff --git a/exercises/practice/change/change.ts b/exercises/practice/change/change.ts new file mode 100644 index 000000000..5cc6464ec --- /dev/null +++ b/exercises/practice/change/change.ts @@ -0,0 +1,3 @@ +export const findFewestCoins = (coins: unknown, target: unknown): unknown => { + throw new Error('Remove this statement and implement this function') +} diff --git a/exercises/practice/change/eslint.config.mjs b/exercises/practice/change/eslint.config.mjs new file mode 100644 index 000000000..1be39c53f --- /dev/null +++ b/exercises/practice/change/eslint.config.mjs @@ -0,0 +1,26 @@ +// @ts-check + +import tsEslint from 'typescript-eslint' +import config from '@exercism/eslint-config-typescript' +import maintainersConfig from '@exercism/eslint-config-typescript/maintainers.mjs' + +export default [ + ...tsEslint.config(...config, { + files: ['.meta/proof.ci.ts', '.meta/exemplar.ts', '*.test.ts'], + extends: maintainersConfig, + }), + { + ignores: [ + // # Protected or generated + '.git/**/*', + '.vscode/**/*', + + //# When using npm + 'node_modules/**/*', + + // # Configuration files + 'babel.config.cjs', + 'jest.config.cjs', + ], + }, +] diff --git a/exercises/practice/change/jest.config.cjs b/exercises/practice/change/jest.config.cjs new file mode 100644 index 000000000..0aba1a59e --- /dev/null +++ b/exercises/practice/change/jest.config.cjs @@ -0,0 +1,22 @@ +module.exports = { + verbose: true, + projects: [''], + testMatch: [ + '**/__tests__/**/*.[jt]s?(x)', + '**/test/**/*.[jt]s?(x)', + '**/?(*.)+(spec|test).[jt]s?(x)', + ], + testPathIgnorePatterns: [ + '/(?:production_)?node_modules/', + '.d.ts$', + '/test/fixtures', + '/test/helpers', + '__mocks__', + ], + transform: { + '^.+\\.[jt]sx?$': 'babel-jest', + }, + moduleNameMapper: { + '^(\\.\\/.+)\\.js$': '$1', + }, +} diff --git a/exercises/practice/change/package.json b/exercises/practice/change/package.json new file mode 100644 index 000000000..3155d08a0 --- /dev/null +++ b/exercises/practice/change/package.json @@ -0,0 +1,38 @@ +{ + "name": "@exercism/typescript-change", + "version": "1.0.0", + "description": "Exercism exercises in Typescript.", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/exercism/typescript" + }, + "type": "module", + "engines": { + "node": "^18.16.0 || >=20.0.0" + }, + "devDependencies": { + "@exercism/babel-preset-typescript": "^0.6.0", + "@exercism/eslint-config-typescript": "^0.8.0", + "@jest/globals": "^29.7.0", + "@types/node": "~22.7.6", + "babel-jest": "^29.7.0", + "core-js": "~3.38.1", + "eslint": "^9.12.0", + "expect": "^29.7.0", + "jest": "^29.7.0", + "prettier": "^3.3.3", + "tstyche": "^2.1.1", + "typescript": "~5.6.3", + "typescript-eslint": "^8.10.0" + }, + "scripts": { + "test": "corepack yarn node test-runner.mjs", + "test:types": "corepack yarn tstyche", + "test:implementation": "corepack yarn jest --no-cache --passWithNoTests", + "lint": "corepack yarn lint:types && corepack yarn lint:ci", + "lint:types": "corepack yarn tsc --noEmit -p .", + "lint:ci": "corepack yarn eslint . --ext .tsx,.ts" + }, + "packageManager": "yarn@4.5.1" +} diff --git a/exercises/practice/change/test-runner.mjs b/exercises/practice/change/test-runner.mjs new file mode 100644 index 000000000..44b205fc2 --- /dev/null +++ b/exercises/practice/change/test-runner.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +/** + * 👋🏽 Hello there reader, + * + * It looks like you are working on this solution using the Exercism CLI and + * not the online editor. That's great! The file you are looking at executes + * the various steps the online test-runner also takes. + * + * @see https://github.com/exercism/typescript-test-runner + * + * TypeScript track exercises generally consist of at least two out of three + * types of tests to run. + * + * 1. tsc, the TypeScript compiler. This tests if the TypeScript code is valid + * 2. tstyche, static analysis tests to see if the types used are expected + * 3. jest, runtime implementation tests to see if the solution is correct + * + * If one of these three fails, this script terminates with -1, -2, or -3 + * respectively. If it succeeds, it terminates with exit code 0. + * + * @note you need corepack (bundled with node LTS) enabled in order for this + * test runner to work as expected. Follow the installation and test + * instructions if you see errors about corepack or pnp. + */ + +import { execSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { exit } from 'node:process' +import { URL } from 'node:url' + +/** + * Before executing any tests, the test runner attempts to find the + * exercise config.json file which has metadata about which types of tests + * to run for this solution. + */ +const metaDirectory = new URL('./.meta/', import.meta.url) +const exercismDirectory = new URL('./.exercism/', import.meta.url) +const configDirectory = existsSync(metaDirectory) + ? metaDirectory + : existsSync(exercismDirectory) + ? exercismDirectory + : null + +if (configDirectory === null) { + throw new Error( + 'Expected .meta or .exercism directory to exist, but I cannot find it.' + ) +} + +const configFile = new URL('./config.json', configDirectory) +if (!existsSync(configFile)) { + throw new Error('Expected config.json to exist at ' + configFile.toString()) +} + +// Experimental: import config from './config.json' with { type: 'json' } +/** @type {import('./config.json') } */ +const config = JSON.parse(readFileSync(configFile)) + +const jest = !config.custom || config.custom['flag.tests.jest'] +const tstyche = config.custom?.['flag.tests.tstyche'] +console.log( + `[tests] tsc: ✅, tstyche: ${tstyche ? '✅' : '❌'}, jest: ${jest ? '✅' : '❌'}, ` +) + +/** + * 1. tsc: the typescript compiler + */ +try { + console.log('[tests] tsc (compile)') + execSync('corepack yarn lint:types', { + stdio: 'inherit', + cwd: process.cwd(), + }) +} catch { + exit(-1) +} + +/** + * 2. tstyche: type tests + */ +if (tstyche) { + try { + console.log('[tests] tstyche (type tests)') + execSync('corepack yarn test:types', { + stdio: 'inherit', + cwd: process.cwd(), + }) + } catch { + exit(-2) + } +} + +/** + * 3. jest: implementation tests + */ +if (jest) { + try { + console.log('[tests] tstyche (implementation tests)') + execSync('corepack yarn test:implementation', { + stdio: 'inherit', + cwd: process.cwd(), + }) + } catch { + exit(-3) + } +} + +/** + * Done! 🥳 + */ diff --git a/exercises/practice/change/tsconfig.json b/exercises/practice/change/tsconfig.json new file mode 100644 index 000000000..574616245 --- /dev/null +++ b/exercises/practice/change/tsconfig.json @@ -0,0 +1,38 @@ +{ + "display": "Configuration for Exercism TypeScript Exercises", + "compilerOptions": { + // Allows you to use the newest syntax, and have access to console.log + // https://www.typescriptlang.org/tsconfig#lib + "lib": ["ES2020", "dom"], + // Make sure typescript is configured to output ESM + // https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#how-can-i-make-my-typescript-project-output-esm + "module": "Node16", + // Since this project is using babel, TypeScript may target something very + // high, and babel will make sure it runs on your local Node version. + // https://babeljs.io/docs/en/ + "target": "ES2020", // ESLint doesn't support this yet: "es2022", + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + + // Because jest-resolve isn't like node resolve, the absolute path must be .ts + "allowImportingTsExtensions": true, + "noEmit": true, + + // Because we'll be using babel: ensure that Babel can safely transpile + // files in the TypeScript project. + // + // https://babeljs.io/docs/en/babel-plugin-transform-typescript/#caveats + "isolatedModules": true + }, + "include": [ + "*.ts", + "*.tsx", + ".meta/*.ts", + ".meta/*.tsx", + "__typetests__/*.tst.ts" + ], + "exclude": ["node_modules"] +} diff --git a/exercises/practice/change/yarn.lock b/exercises/practice/change/yarn.lock new file mode 100644 index 000000000..e69de29bb