diff --git a/config.json b/config.json index 67c47919c..cea3ee17a 100644 --- a/config.json +++ b/config.json @@ -1408,6 +1408,14 @@ "lists" ] }, + { + "slug": "affine-cipher", + "name": "Affine Cipher", + "uuid": "2326f3c2-4e92-4111-b2f9-6d6a3bcbdfca", + "practices": [], + "prerequisites": [], + "difficulty": 5 + }, { "slug": "all-your-base", "name": "All Your Base", diff --git a/exercises/practice/affine-cipher/.docs/instructions.md b/exercises/practice/affine-cipher/.docs/instructions.md new file mode 100644 index 000000000..1603dbbce --- /dev/null +++ b/exercises/practice/affine-cipher/.docs/instructions.md @@ -0,0 +1,74 @@ +# Instructions + +Create an implementation of the affine cipher, an ancient encryption system created in the Middle East. + +The affine cipher is a type of monoalphabetic substitution cipher. +Each character is mapped to its numeric equivalent, encrypted with a mathematical function and then converted to the letter relating to its new numeric value. +Although all monoalphabetic ciphers are weak, the affine cipher is much stronger than the Atbash cipher, because it has many more keys. + +[//]: # " monoalphabetic as spelled by Merriam-Webster, compare to polyalphabetic " + +## Encryption + +The encryption function is: + +```text +E(x) = (ai + b) mod m +``` + +Where: + +- `i` is the letter's index from `0` to the length of the alphabet - 1. +- `m` is the length of the alphabet. + For the Latin alphabet `m` is `26`. +- `a` and `b` are integers which make up the encryption key. + +Values `a` and `m` must be _coprime_ (or, _relatively prime_) for automatic decryption to succeed, i.e., they have number `1` as their only common factor (more information can be found in the [Wikipedia article about coprime integers][coprime-integers]). +In case `a` is not coprime to `m`, your program should indicate that this is an error. +Otherwise it should encrypt or decrypt with the provided key. + +For the purpose of this exercise, digits are valid input but they are not encrypted. +Spaces and punctuation characters are excluded. +Ciphertext is written out in groups of fixed length separated by space, the traditional group size being `5` letters. +This is to make it harder to guess encrypted text based on word boundaries. + +## Decryption + +The decryption function is: + +```text +D(y) = (a^-1)(y - b) mod m +``` + +Where: + +- `y` is the numeric value of an encrypted letter, i.e., `y = E(x)` +- it is important to note that `a^-1` is the modular multiplicative inverse (MMI) of `a mod m` +- the modular multiplicative inverse only exists if `a` and `m` are coprime. + +The MMI of `a` is `x` such that the remainder after dividing `ax` by `m` is `1`: + +```text +ax mod m = 1 +``` + +More information regarding how to find a Modular Multiplicative Inverse and what it means can be found in the [related Wikipedia article][mmi]. + +## General Examples + +- Encrypting `"test"` gives `"ybty"` with the key `a = 5`, `b = 7` +- Decrypting `"ybty"` gives `"test"` with the key `a = 5`, `b = 7` +- Decrypting `"ybty"` gives `"lqul"` with the wrong key `a = 11`, `b = 7` +- Decrypting `"kqlfd jzvgy tpaet icdhm rtwly kqlon ubstx"` gives `"thequickbrownfoxjumpsoverthelazydog"` with the key `a = 19`, `b = 13` +- Encrypting `"test"` with the key `a = 18`, `b = 13` is an error because `18` and `26` are not coprime + +## Example of finding a Modular Multiplicative Inverse (MMI) + +Finding MMI for `a = 15`: + +- `(15 * x) mod 26 = 1` +- `(15 * 7) mod 26 = 1`, ie. `105 mod 26 = 1` +- `7` is the MMI of `15 mod 26` + +[mmi]: https://en.wikipedia.org/wiki/Modular_multiplicative_inverse +[coprime-integers]: https://en.wikipedia.org/wiki/Coprime_integers diff --git a/exercises/practice/affine-cipher/.meta/config.json b/exercises/practice/affine-cipher/.meta/config.json new file mode 100644 index 000000000..d7db95ef7 --- /dev/null +++ b/exercises/practice/affine-cipher/.meta/config.json @@ -0,0 +1,19 @@ +{ + "authors": [ + "BNAndras" + ], + "files": { + "solution": [ + "affine-cipher.ts" + ], + "test": [ + "affine-cipher.test.ts" + ], + "example": [ + ".meta/proof.ci.ts" + ] + }, + "blurb": "Create an implementation of the Affine cipher, an ancient encryption algorithm from the Middle East.", + "source": "Wikipedia", + "source_url": "https://en.wikipedia.org/wiki/Affine_cipher" +} diff --git a/exercises/practice/affine-cipher/.meta/proof.ci.ts b/exercises/practice/affine-cipher/.meta/proof.ci.ts new file mode 100644 index 000000000..4595132d3 --- /dev/null +++ b/exercises/practice/affine-cipher/.meta/proof.ci.ts @@ -0,0 +1,65 @@ +export type Key = { + a: number + b: number +} + +const alphabetLength = 26 + +const mod = (value: number, modulus: number): number => + ((value % modulus) + modulus) % modulus + +const gcd = (a: number, b: number): number => { + while (b !== 0) { + const remainder = mod(a, b) + a = b + b = remainder + } + + return Math.abs(a) +} + +const assertValidKey = (key: Key): void => { + if (gcd(key.a, alphabetLength) !== 1) { + throw new Error('a and m must be coprime.') + } +} + +const modularInverse = (value: number): number => { + for (let candidate = 1; candidate < alphabetLength; candidate += 1) { + if (mod(value * candidate, alphabetLength) === 1) { + return candidate + } + } + + throw new Error('a and m must be coprime.') +} + +const normalize = (phrase: string): string => + phrase.toLowerCase().replace(/[^a-z0-9]/g, '') + +const chunk = (text: string): string => text.match(/.{1,5}/g)?.join(' ') ?? '' + +const letterIndex = (char: string): number => + char.charCodeAt(0) - 'a'.charCodeAt(0) + +const letterFor = (index: number): string => + String.fromCharCode(mod(index, alphabetLength) + 'a'.charCodeAt(0)) + +const translate = ( + text: string, + transformIndex: (index: number) => number +): string => + text.replace(/[a-z]/g, (char) => letterFor(transformIndex(letterIndex(char)))) + +export const encode = (phrase: string, key: Key): string => { + assertValidKey(key) + + return chunk(translate(normalize(phrase), (index) => key.a * index + key.b)) +} + +export const decode = (phrase: string, key: Key): string => { + assertValidKey(key) + + const inverse = modularInverse(key.a) + return translate(normalize(phrase), (index) => inverse * (index - key.b)) +} diff --git a/exercises/practice/affine-cipher/.meta/tests.toml b/exercises/practice/affine-cipher/.meta/tests.toml new file mode 100644 index 000000000..07cce7c77 --- /dev/null +++ b/exercises/practice/affine-cipher/.meta/tests.toml @@ -0,0 +1,58 @@ +# 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. + +[2ee1d9af-1c43-416c-b41b-cefd7d4d2b2a] +description = "encode -> encode yes" + +[785bade9-e98b-4d4f-a5b0-087ba3d7de4b] +description = "encode -> encode no" + +[2854851c-48fb-40d8-9bf6-8f192ed25054] +description = "encode -> encode OMG" + +[bc0c1244-b544-49dd-9777-13a770be1bad] +description = "encode -> encode O M G" + +[381a1a20-b74a-46ce-9277-3778625c9e27] +description = "encode -> encode mindblowingly" + +[6686f4e2-753b-47d4-9715-876fdc59029d] +description = "encode -> encode numbers" + +[ae23d5bd-30a8-44b6-afbe-23c8c0c7faa3] +description = "encode -> encode deep thought" + +[c93a8a4d-426c-42ef-9610-76ded6f7ef57] +description = "encode -> encode all the letters" + +[0673638a-4375-40bd-871c-fb6a2c28effb] +description = "encode -> encode with a not coprime to m" + +[3f0ac7e2-ec0e-4a79-949e-95e414953438] +description = "decode -> decode exercism" + +[241ee64d-5a47-4092-a5d7-7939d259e077] +description = "decode -> decode a sentence" + +[33fb16a1-765a-496f-907f-12e644837f5e] +description = "decode -> decode numbers" + +[20bc9dce-c5ec-4db6-a3f1-845c776bcbf7] +description = "decode -> decode all the letters" + +[623e78c0-922d-49c5-8702-227a3e8eaf81] +description = "decode -> decode with no spaces in input" + +[58fd5c2a-1fd9-4563-a80a-71cff200f26f] +description = "decode -> decode with too many spaces" + +[b004626f-c186-4af9-a3f4-58f74cdb86d5] +description = "decode -> decode with a not coprime to m" diff --git a/exercises/practice/affine-cipher/.vscode/extensions.json b/exercises/practice/affine-cipher/.vscode/extensions.json new file mode 100644 index 000000000..daaa5ee2e --- /dev/null +++ b/exercises/practice/affine-cipher/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "arcanis.vscode-zipfs", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} diff --git a/exercises/practice/affine-cipher/.vscode/settings.json b/exercises/practice/affine-cipher/.vscode/settings.json new file mode 100644 index 000000000..761fb422a --- /dev/null +++ b/exercises/practice/affine-cipher/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": ["exercism"], + "search.exclude": { + "**/.yarn": true, + "**/.pnp.*": true + } +} diff --git a/exercises/practice/affine-cipher/.yarnrc.yml b/exercises/practice/affine-cipher/.yarnrc.yml new file mode 100644 index 000000000..23e4a6d3d --- /dev/null +++ b/exercises/practice/affine-cipher/.yarnrc.yml @@ -0,0 +1,3 @@ +compressionLevel: mixed + +enableGlobalCache: true diff --git a/exercises/practice/affine-cipher/affine-cipher.test.ts b/exercises/practice/affine-cipher/affine-cipher.test.ts new file mode 100644 index 000000000..19d357d4b --- /dev/null +++ b/exercises/practice/affine-cipher/affine-cipher.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, xit } from '@jest/globals' +import { encode, decode } from './affine-cipher.ts' + +describe('Affine Cipher', () => { + describe('encode', () => { + it('encode yes', () => { + const key = { a: 5, b: 7 } + expect(encode('yes', key)).toEqual('xbt') + }) + + xit('encode no', () => { + const key = { a: 15, b: 18 } + expect(encode('no', key)).toEqual('fu') + }) + + xit('encode OMG', () => { + const key = { a: 21, b: 3 } + expect(encode('OMG', key)).toEqual('lvz') + }) + + xit('encode O M G', () => { + const key = { a: 25, b: 47 } + expect(encode('O M G', key)).toEqual('hjp') + }) + + xit('encode mindblowingly', () => { + const key = { a: 11, b: 15 } + expect(encode('mindblowingly', key)).toEqual('rzcwa gnxzc dgt') + }) + + xit('encode numbers', () => { + const key = { a: 3, b: 4 } + expect(encode('Testing,1 2 3, testing.', key)).toEqual( + 'jqgjc rw123 jqgjc rw' + ) + }) + + xit('encode deep thought', () => { + const key = { a: 5, b: 17 } + expect(encode('Truth is fiction.', key)).toEqual('iynia fdqfb ifje') + }) + + xit('encode all the letters', () => { + const key = { a: 17, b: 33 } + const phrase = 'The quick brown fox jumps over the lazy dog.' + const expected = 'swxtj npvyk lruol iejdc blaxk swxmh qzglf' + expect(encode(phrase, key)).toEqual(expected) + }) + + xit('encode with a not coprime to m', () => { + const key = { a: 6, b: 17 } + expect(() => encode('This is a test.', key)).toThrow( + 'a and m must be coprime.' + ) + }) + }) + + describe('decode', () => { + xit('decode exercism', () => { + const key = { a: 3, b: 7 } + expect(decode('tytgn fjr', key)).toEqual('exercism') + }) + + xit('decode a sentence', () => { + const key = { a: 19, b: 16 } + const phrase = 'qdwju nqcro muwhn odqun oppmd aunwd o' + const expected = 'anobstacleisoftenasteppingstone' + expect(decode(phrase, key)).toEqual(expected) + }) + + xit('decode numbers', () => { + const key = { a: 25, b: 7 } + expect(decode('odpoz ub123 odpoz ub', key)).toEqual('testing123testing') + }) + + xit('decode all the letters', () => { + const key = { a: 17, b: 33 } + const phrase = 'swxtj npvyk lruol iejdc blaxk swxmh qzglf' + const expected = 'thequickbrownfoxjumpsoverthelazydog' + expect(decode(phrase, key)).toEqual(expected) + }) + + xit('decode with no spaces in input', () => { + const key = { a: 17, b: 33 } + const phrase = 'swxtjnpvyklruoliejdcblaxkswxmhqzglf' + const expected = 'thequickbrownfoxjumpsoverthelazydog' + expect(decode(phrase, key)).toEqual(expected) + }) + + xit('decode with too many spaces', () => { + const key = { a: 15, b: 16 } + expect(decode('vszzm cly yd cg qdp', key)).toEqual( + 'jollygreengiant' + ) + }) + + xit('decode with a not coprime to m', () => { + const key = { a: 13, b: 5 } + expect(() => decode('Test', key)).toThrow('a and m must be coprime.') + }) + }) +}) diff --git a/exercises/practice/affine-cipher/affine-cipher.ts b/exercises/practice/affine-cipher/affine-cipher.ts new file mode 100644 index 000000000..a5d01d1b6 --- /dev/null +++ b/exercises/practice/affine-cipher/affine-cipher.ts @@ -0,0 +1,7 @@ +export const encode = (phrase: unknown, key: unknown): unknown => { + throw new Error('Remove this line and implement the function') +} + +export const decode = (phrase: unknown, key: unknown): unknown => { + throw new Error('Remove this line and implement the function') +} diff --git a/exercises/practice/affine-cipher/babel.config.cjs b/exercises/practice/affine-cipher/babel.config.cjs new file mode 100644 index 000000000..164552797 --- /dev/null +++ b/exercises/practice/affine-cipher/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/affine-cipher/eslint.config.mjs b/exercises/practice/affine-cipher/eslint.config.mjs new file mode 100644 index 000000000..1be39c53f --- /dev/null +++ b/exercises/practice/affine-cipher/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/affine-cipher/jest.config.cjs b/exercises/practice/affine-cipher/jest.config.cjs new file mode 100644 index 000000000..0aba1a59e --- /dev/null +++ b/exercises/practice/affine-cipher/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/affine-cipher/package.json b/exercises/practice/affine-cipher/package.json new file mode 100644 index 000000000..2df170a4a --- /dev/null +++ b/exercises/practice/affine-cipher/package.json @@ -0,0 +1,38 @@ +{ + "name": "@exercism/typescript-affine-cipher", + "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/affine-cipher/test-runner.mjs b/exercises/practice/affine-cipher/test-runner.mjs new file mode 100644 index 000000000..44b205fc2 --- /dev/null +++ b/exercises/practice/affine-cipher/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/affine-cipher/tsconfig.json b/exercises/practice/affine-cipher/tsconfig.json new file mode 100644 index 000000000..574616245 --- /dev/null +++ b/exercises/practice/affine-cipher/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/affine-cipher/yarn.lock b/exercises/practice/affine-cipher/yarn.lock new file mode 100644 index 000000000..e69de29bb