@doikayt/typescript-build-config 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/package.json +3 -3
- package/src/canonical-scripts.js +32 -5
- package/src/init.js +94 -4
- package/src/package-json.js +16 -7
- package/src/templates/demo/README.md +42 -0
- package/src/templates/demo/project.json +24 -0
- package/src/templates/demo/src/index.ts +7 -0
- package/src/templates/demo/src/math-engine/MathEngine.test.ts +24 -0
- package/src/templates/demo/src/math-engine/MathEngine.ts +22 -0
- package/src/templates/demo/src/math-engine/MathError.ts +11 -0
- package/src/templates/demo/src/math-engine/MathResult.ts +7 -0
- package/src/templates/demo/src/math-engine/Operation.ts +1 -0
- package/src/templates/demo/src/math-engine/_COMPONENT_INFO.md +2 -0
- package/src/top-level/tsconfig.json +2 -1
package/README.md
CHANGED
|
@@ -110,25 +110,42 @@ small single-purpose scripts, and the check side mirrors the write side:
|
|
|
110
110
|
| Script | What it does |
|
|
111
111
|
| --- | --- |
|
|
112
112
|
| `ci` | The release gate: `check-all-format` then `test` (plus `test:e2e` for UI). |
|
|
113
|
+
| `build` | Compile TypeScript to `dist/` (`tsc`). |
|
|
113
114
|
| `test` | Unit / integration tests (`vitest run`). |
|
|
114
115
|
| `test:e2e` | End-to-end tests (`playwright test`) — UI projects only. |
|
|
116
|
+
| `prepack` | Runs `build` before packing, so `changeset publish` ships compiled `dist/` — **libraries only**. |
|
|
115
117
|
| `update-all-format` | Reformat everything: code + markdown docs (write). |
|
|
116
118
|
| `check-all-format` | Verify formatting: code + markdown docs (used by `ci`). |
|
|
117
119
|
| `update-code-formatting` / `check-code-formatting` | Prettier write / check. |
|
|
118
120
|
| `update-markdown-docs` / `check-markdown-docs` | Regenerate / verify generated markdown (TOC, UML, …). |
|
|
119
121
|
|
|
122
|
+
`init` also asks whether the project is a **publishable library**, which sets the
|
|
123
|
+
publish config accordingly (non-destructively):
|
|
124
|
+
|
|
125
|
+
- **Library** — `main` / `types` / `exports` → `dist/index.js`, `files: ["dist"]`,
|
|
126
|
+
and `prepack`, so it publishes a compiled, typed ESM package on push.
|
|
127
|
+
- **App / CLI** (the default) — marked `private: true`, so a release versions and
|
|
128
|
+
tags it but never publishes to npm.
|
|
129
|
+
|
|
130
|
+
`type: "module"` and `build` are set either way. New to these terms (compiled
|
|
131
|
+
`dist/`, publish fields, library vs app)? See
|
|
132
|
+
[Packaging concepts](docs/packaging-concepts.md) for the background.
|
|
133
|
+
|
|
120
134
|
### Console / CLI project
|
|
121
135
|
|
|
122
136
|
```bash
|
|
123
137
|
npm init -y # if starting fresh
|
|
124
138
|
npm install --save-dev @doikayt/typescript-build-config
|
|
125
|
-
npx @doikayt/typescript-build-config init # answer "n" to
|
|
139
|
+
npx @doikayt/typescript-build-config init # answer "n" to Playwright; "n" (app) or "y" (library)
|
|
126
140
|
npm install # fetch the declared devDependencies
|
|
127
141
|
```
|
|
128
142
|
|
|
129
|
-
`init` writes the canonical scripts (`ci`, `test`, `update-all-format`,
|
|
143
|
+
`init` writes the canonical scripts (`ci`, `build`, `test`, `update-all-format`,
|
|
130
144
|
`check-all-format`, and their sub-tasks), declares `vitest` +
|
|
131
|
-
`@doikayt/autogen-markdown-doc`, and seeds a commented
|
|
145
|
+
`@doikayt/autogen-markdown-doc` + `typescript`, and seeds a commented
|
|
146
|
+
`vitest.config.ts`. Answering the "publishable library?" prompt sets the publish
|
|
147
|
+
config — `private` for an app, or `prepack` + the `dist/`-based fields for a
|
|
148
|
+
library (see [The canonical script set](#the-canonical-script-set)).
|
|
132
149
|
|
|
133
150
|
### UI / web project (with Playwright e2e)
|
|
134
151
|
|
|
@@ -487,6 +504,10 @@ To auto-fix formatting before verifying:
|
|
|
487
504
|
npm run update-all-format && npm run ci
|
|
488
505
|
```
|
|
489
506
|
|
|
507
|
+
To verify end-to-end that `init` scaffolds and ships both a library and an app —
|
|
508
|
+
by hand, with what to look for at each step — follow
|
|
509
|
+
[docs/e2e-verification.md](docs/e2e-verification.md).
|
|
510
|
+
|
|
490
511
|
## License
|
|
491
512
|
|
|
492
513
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doikayt/typescript-build-config",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Shared build configuration for TypeScript projects — ESLint, Prettier, tsconfig, and Playwright presets",
|
|
5
5
|
"_comments": {
|
|
6
6
|
"exports": "This block lists the config files that will be extended by files in in src/toplevel - which are copied to consuming project root folder. An example: eslint.config.js imports from '@doikayt/typescript-build-config/eslint'.",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"postinstall": "node src/postinstall.js",
|
|
29
29
|
"update-all-format": "prettier --write src/ && update-markdown-toc README.md",
|
|
30
30
|
"check-format": "prettier --check src/ && update-markdown-toc --check README.md",
|
|
31
|
-
"ci": "npm run check-format && node --test",
|
|
32
|
-
"test": "node --test"
|
|
31
|
+
"ci": "npm run check-format && node --test tests/*.test.js",
|
|
32
|
+
"test": "node --test tests/*.test.js"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|
package/src/canonical-scripts.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// The canonical npm-script set init writes into every consumer. package.json is
|
|
2
2
|
// the single source of truth (it is always present); NX projects, if any, add
|
|
3
|
-
// thin targets that delegate to `npm run <x>` — never the reverse.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// thin targets that delegate to `npm run <x>` — never the reverse. Two axes vary
|
|
4
|
+
// the set: UI/console (Playwright e2e) and library/app. `build` is universal
|
|
5
|
+
// (both archetypes compile); only a publishable library adds `prepack`, which
|
|
6
|
+
// builds dist/ at publish time so `changeset publish` ships compiled output.
|
|
6
7
|
|
|
7
|
-
export function canonicalScripts({ ui = false } = {}) {
|
|
8
|
+
export function canonicalScripts({ ui = false, library = false } = {}) {
|
|
8
9
|
const ci = ui
|
|
9
10
|
? "npm run check-all-format && npm run test && npm run test:e2e"
|
|
10
11
|
: "npm run check-all-format && npm run test";
|
|
11
12
|
|
|
12
13
|
const scripts = {
|
|
13
14
|
ci,
|
|
15
|
+
build: "tsc",
|
|
14
16
|
test: "vitest run",
|
|
15
17
|
"update-all-format":
|
|
16
18
|
"npm run update-code-formatting && npm run update-markdown-docs",
|
|
@@ -23,12 +25,37 @@ export function canonicalScripts({ ui = false } = {}) {
|
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
if (ui) scripts["test:e2e"] = "doikayt-playwright-install && playwright test";
|
|
28
|
+
if (library) scripts.prepack = "npm run build";
|
|
26
29
|
|
|
27
30
|
return scripts;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function devDependencyNames({ ui = false } = {}) {
|
|
31
|
-
const names = ["vitest", "@doikayt/autogen-markdown-doc"];
|
|
34
|
+
const names = ["vitest", "@doikayt/autogen-markdown-doc", "typescript"];
|
|
32
35
|
if (ui) names.push("@playwright/test");
|
|
33
36
|
return names;
|
|
34
37
|
}
|
|
38
|
+
|
|
39
|
+
// Top-level package.json fields init sets (non-destructively). `type: "module"`
|
|
40
|
+
// is universal. A publishable **library** also gets the dist/-based entry points
|
|
41
|
+
// (main/types/exports) and the tarball allow-list (files), assuming the canonical
|
|
42
|
+
// src/index.ts entry. An **app** is instead marked `private` so `changeset
|
|
43
|
+
// publish` never publishes it — its release is a version bump + tag only. Either
|
|
44
|
+
// way, fields a consumer already set are left untouched by the caller.
|
|
45
|
+
export function packageFields({ library = false } = {}) {
|
|
46
|
+
const fields = { type: "module" };
|
|
47
|
+
if (library) {
|
|
48
|
+
fields.main = "dist/index.js";
|
|
49
|
+
fields.types = "dist/index.d.ts";
|
|
50
|
+
fields.exports = {
|
|
51
|
+
".": {
|
|
52
|
+
types: "./dist/index.d.ts",
|
|
53
|
+
default: "./dist/index.js",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
fields.files = ["dist"];
|
|
57
|
+
} else {
|
|
58
|
+
fields.private = true;
|
|
59
|
+
}
|
|
60
|
+
return fields;
|
|
61
|
+
}
|
package/src/init.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
} from "fs";
|
|
2
9
|
import { resolve } from "path";
|
|
3
10
|
import { fileURLToPath } from "url";
|
|
4
11
|
import { createInterface } from "readline";
|
|
5
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
canonicalScripts,
|
|
14
|
+
devDependencyNames,
|
|
15
|
+
packageFields,
|
|
16
|
+
} from "./canonical-scripts.js";
|
|
6
17
|
import { applyToPackageJson } from "./package-json.js";
|
|
7
18
|
import { resolveVersions } from "./dep-versions.js";
|
|
8
19
|
|
|
@@ -16,6 +27,21 @@ function seedFile(src, destPath) {
|
|
|
16
27
|
return "seeded";
|
|
17
28
|
}
|
|
18
29
|
|
|
30
|
+
// Recursively copy a template directory tree into the project.
|
|
31
|
+
function copyDir(srcDir, destDir) {
|
|
32
|
+
mkdirSync(destDir, { recursive: true });
|
|
33
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
34
|
+
const from = resolve(srcDir, entry.name);
|
|
35
|
+
const to = resolve(destDir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) copyDir(from, to);
|
|
37
|
+
else copyFileSync(from, to);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dirIsEmpty(dir) {
|
|
42
|
+
return !existsSync(dir) || readdirSync(dir).length === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
// Default interactive prompt. Injectable so runInit is testable without stdin.
|
|
20
46
|
async function askYesNo(question) {
|
|
21
47
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -40,12 +66,18 @@ export async function runInit({
|
|
|
40
66
|
"Is this a UI project that needs Playwright (e2e)? [y/N] ",
|
|
41
67
|
);
|
|
42
68
|
|
|
43
|
-
const
|
|
69
|
+
const library = await prompt(
|
|
70
|
+
"Is this a publishable library (published to npm)? Apps/CLIs answer No and\n" +
|
|
71
|
+
"are marked private so they are never published. [y/N] ",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const scripts = canonicalScripts({ ui, library });
|
|
44
75
|
const devDependencies = resolveDevVersions(devDependencyNames({ ui }));
|
|
45
76
|
|
|
46
77
|
const { text, added, skipped } = applyToPackageJson(raw, {
|
|
47
78
|
scripts,
|
|
48
79
|
devDependencies,
|
|
80
|
+
fields: packageFields({ library }),
|
|
49
81
|
});
|
|
50
82
|
writeFileSync(pkgPath, text);
|
|
51
83
|
|
|
@@ -65,10 +97,68 @@ export async function runInit({
|
|
|
65
97
|
report("Kept existing scripts", skipped.scripts);
|
|
66
98
|
report("Declared devDependencies", added.devDependencies);
|
|
67
99
|
report("Kept existing devDependencies", skipped.devDependencies);
|
|
100
|
+
report("Set package fields", added.fields);
|
|
101
|
+
report("Kept existing package fields", skipped.fields);
|
|
68
102
|
report("Seeded config files", seededConfigs);
|
|
69
103
|
report("Kept existing config files", keptConfigs);
|
|
70
104
|
log("");
|
|
71
105
|
log("Next: run `npm install` to fetch the newly declared devDependencies.");
|
|
72
106
|
|
|
73
|
-
|
|
107
|
+
const demo = await maybeScaffoldDemo({ cwd, prompt, log });
|
|
108
|
+
|
|
109
|
+
return { added, skipped, seededConfigs, keptConfigs, demo };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Opt-in starter scaffold for a new project. Seeds a minimal src/ module, a
|
|
113
|
+
// README with doc-generator markers, and a project.json — each independently and
|
|
114
|
+
// only when its slot is empty, so nothing a consumer already has is overwritten.
|
|
115
|
+
// It writes files only; the user runs npm install / test / push themselves.
|
|
116
|
+
async function maybeScaffoldDemo({ cwd, prompt, log }) {
|
|
117
|
+
const wantDemo = await prompt(
|
|
118
|
+
"Scaffold a starter demo (minimal src module + README + project.json) so you\n" +
|
|
119
|
+
"can build, test, and release immediately? Existing files are never\n" +
|
|
120
|
+
"overwritten. [y/N] ",
|
|
121
|
+
);
|
|
122
|
+
if (!wantDemo) {
|
|
123
|
+
return { scaffolded: false, reason: "declined", wrote: [], skipped: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const demoTemplate = resolve(templatesDir, "demo");
|
|
127
|
+
const wrote = [];
|
|
128
|
+
const skipped = [];
|
|
129
|
+
const paths = [];
|
|
130
|
+
|
|
131
|
+
// src/: seed the module only when src/ has no files, so starter code never
|
|
132
|
+
// gets mixed into a project that already has source.
|
|
133
|
+
const srcDir = resolve(cwd, "src");
|
|
134
|
+
if (dirIsEmpty(srcDir)) {
|
|
135
|
+
copyDir(resolve(demoTemplate, "src"), srcDir);
|
|
136
|
+
for (const entry of readdirSync(resolve(demoTemplate, "src"))) {
|
|
137
|
+
paths.push(`src/${entry}`);
|
|
138
|
+
}
|
|
139
|
+
wrote.push("src/ module");
|
|
140
|
+
} else {
|
|
141
|
+
skipped.push("src/ (already has files)");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// README.md and project.json: single files, seeded only when absent.
|
|
145
|
+
for (const name of ["README.md", "project.json"]) {
|
|
146
|
+
if (existsSync(resolve(cwd, name))) {
|
|
147
|
+
skipped.push(name);
|
|
148
|
+
} else {
|
|
149
|
+
copyFileSync(resolve(demoTemplate, name), resolve(cwd, name));
|
|
150
|
+
wrote.push(name);
|
|
151
|
+
paths.push(name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log("");
|
|
156
|
+
if (wrote.length) log(`Demo: seeded ${wrote.join(", ")}.`);
|
|
157
|
+
if (skipped.length) log(`Demo: kept existing ${skipped.join(", ")}.`);
|
|
158
|
+
if (paths.length) {
|
|
159
|
+
log(`To remove the starter files later: rm -r ${paths.join(" ")}`);
|
|
160
|
+
log("Then: npm install → npm test → npm run update-all-format → push.");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { scaffolded: paths.length > 0, reason: null, wrote, skipped, paths };
|
|
74
164
|
}
|
package/src/package-json.js
CHANGED
|
@@ -28,24 +28,29 @@ export function detectIndent(text) {
|
|
|
28
28
|
* not touch disk — returns the new text plus a report of what changed.
|
|
29
29
|
*
|
|
30
30
|
* @param {string} rawText - current package.json contents
|
|
31
|
-
* @param {{
|
|
31
|
+
* @param {{
|
|
32
|
+
* scripts?: Record<string,string>,
|
|
33
|
+
* devDependencies?: Record<string,string>,
|
|
34
|
+
* fields?: Record<string,unknown>
|
|
35
|
+
* }} additions - `fields` are top-level keys (main, type, files, …)
|
|
32
36
|
* @returns {{
|
|
33
37
|
* text: string,
|
|
34
|
-
* added: { scripts: string[], devDependencies: string[] },
|
|
35
|
-
* skipped: { scripts: string[], devDependencies: string[] }
|
|
38
|
+
* added: { scripts: string[], devDependencies: string[], fields: string[] },
|
|
39
|
+
* skipped: { scripts: string[], devDependencies: string[], fields: string[] }
|
|
36
40
|
* }} new file text, plus keys added vs. skipped (already present)
|
|
37
41
|
*/
|
|
38
42
|
export function applyToPackageJson(
|
|
39
43
|
rawText,
|
|
40
|
-
{ scripts = {}, devDependencies = {} } = {},
|
|
44
|
+
{ scripts = {}, devDependencies = {}, fields = {} } = {},
|
|
41
45
|
) {
|
|
42
46
|
const pkg = JSON.parse(rawText);
|
|
43
47
|
const indent = detectIndent(rawText);
|
|
44
48
|
|
|
45
49
|
const s = mergeAbsent(pkg.scripts, scripts);
|
|
46
50
|
const d = mergeAbsent(pkg.devDependencies, devDependencies);
|
|
51
|
+
const f = mergeAbsent(pkg, fields);
|
|
47
52
|
|
|
48
|
-
const next = { ...
|
|
53
|
+
const next = { ...f.merged };
|
|
49
54
|
if (Object.keys(s.merged).length) next.scripts = s.merged;
|
|
50
55
|
if (Object.keys(d.merged).length) next.devDependencies = d.merged;
|
|
51
56
|
|
|
@@ -54,7 +59,11 @@ export function applyToPackageJson(
|
|
|
54
59
|
|
|
55
60
|
return {
|
|
56
61
|
text,
|
|
57
|
-
added: { scripts: s.added, devDependencies: d.added },
|
|
58
|
-
skipped: {
|
|
62
|
+
added: { scripts: s.added, devDependencies: d.added, fields: f.added },
|
|
63
|
+
skipped: {
|
|
64
|
+
scripts: s.skipped,
|
|
65
|
+
devDependencies: d.skipped,
|
|
66
|
+
fields: f.skipped,
|
|
67
|
+
},
|
|
59
68
|
};
|
|
60
69
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!-- TOC:START -->
|
|
2
|
+
<!-- TOC:END -->
|
|
3
|
+
|
|
4
|
+
# Starter project
|
|
5
|
+
|
|
6
|
+
> **Seeded by `init` — safe to replace.** These starter files (this README, a
|
|
7
|
+
> `math-engine` component under `src/`, and `project.json`) exist so a brand-new
|
|
8
|
+
> project can run its tests, publish on push, and watch the doc generator work
|
|
9
|
+
> before you write any code. Replace them with your own whenever you like —
|
|
10
|
+
> `init` printed the exact paths to delete.
|
|
11
|
+
|
|
12
|
+
Run `npm run update-all-format` to fill the blocks below; GitHub renders the
|
|
13
|
+
Mermaid diagrams inline from the fenced blocks.
|
|
14
|
+
|
|
15
|
+
## Build Pipeline
|
|
16
|
+
|
|
17
|
+
<!-- NX_GRAPH:START -->
|
|
18
|
+
<!-- NX_GRAPH:END -->
|
|
19
|
+
|
|
20
|
+
## Component Diagram
|
|
21
|
+
|
|
22
|
+
<!-- UML:components:START -->
|
|
23
|
+
<!-- UML:components:END -->
|
|
24
|
+
|
|
25
|
+
## Components Table
|
|
26
|
+
|
|
27
|
+
<!-- UML:components-table:START -->
|
|
28
|
+
<!-- UML:components-table:END -->
|
|
29
|
+
|
|
30
|
+
## Component Details
|
|
31
|
+
|
|
32
|
+
<!-- UML:component-details:START -->
|
|
33
|
+
<!-- UML:component-details:END -->
|
|
34
|
+
|
|
35
|
+
## Try it
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install # fetch the dev deps init declared
|
|
39
|
+
npm test # the seeded vitest test passes
|
|
40
|
+
npm run update-all-format # fills the doc blocks above
|
|
41
|
+
git commit -am "feat: initial version" && git push # feat:/fix: cut a release (npm publish if a library)
|
|
42
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "demo",
|
|
3
|
+
"targets": {
|
|
4
|
+
"install": {
|
|
5
|
+
"description": "Install npm dependencies"
|
|
6
|
+
},
|
|
7
|
+
"build": {
|
|
8
|
+
"description": "Compile TypeScript source to JavaScript",
|
|
9
|
+
"dependsOn": ["install"]
|
|
10
|
+
},
|
|
11
|
+
"lint": {
|
|
12
|
+
"description": "Run ESLint on TypeScript source files",
|
|
13
|
+
"dependsOn": ["install"]
|
|
14
|
+
},
|
|
15
|
+
"type-check": {
|
|
16
|
+
"description": "Type-check source without emitting",
|
|
17
|
+
"dependsOn": ["build"]
|
|
18
|
+
},
|
|
19
|
+
"test": {
|
|
20
|
+
"description": "Run unit tests with vitest",
|
|
21
|
+
"dependsOn": ["build"]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Starter file from `init` — replace with your own code.
|
|
2
|
+
// Package entry point: re-exports the demo component so the built package has a
|
|
3
|
+
// public surface to publish.
|
|
4
|
+
export { MathEngine } from "./math-engine/MathEngine.js";
|
|
5
|
+
export { MathError } from "./math-engine/MathError.js";
|
|
6
|
+
export type { MathResult } from "./math-engine/MathResult.js";
|
|
7
|
+
export type { Operation } from "./math-engine/Operation.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Starter file from `init` — replace with your own code.
|
|
2
|
+
import { describe, test, expect } from "vitest";
|
|
3
|
+
import { MathEngine } from "./MathEngine.js";
|
|
4
|
+
import { MathError } from "./MathError.js";
|
|
5
|
+
|
|
6
|
+
describe("MathEngine", () => {
|
|
7
|
+
const engine = new MathEngine();
|
|
8
|
+
|
|
9
|
+
test("add returns the sum and echoes the operation", () => {
|
|
10
|
+
expect(engine.add(3, 4)).toEqual({
|
|
11
|
+
value: 7,
|
|
12
|
+
operation: "add",
|
|
13
|
+
operands: [3, 4],
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("subtract returns the difference", () => {
|
|
18
|
+
expect(engine.subtract(10, 4).value).toBe(6);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects non-finite operands with a MathError", () => {
|
|
22
|
+
expect(() => engine.add(1, Infinity)).toThrow(MathError);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Starter file from `init` — replace with your own code.
|
|
2
|
+
import { MathError } from "./MathError.js";
|
|
3
|
+
import type { MathResult } from "./MathResult.js";
|
|
4
|
+
import type { Operation } from "./Operation.js";
|
|
5
|
+
|
|
6
|
+
export class MathEngine {
|
|
7
|
+
add(a: number, b: number): MathResult {
|
|
8
|
+
this.validate("add", a, b);
|
|
9
|
+
return { value: a + b, operation: "add", operands: [a, b] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
subtract(a: number, b: number): MathResult {
|
|
13
|
+
this.validate("subtract", a, b);
|
|
14
|
+
return { value: a - b, operation: "subtract", operands: [a, b] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private validate(op: Operation, a: number, b: number): void {
|
|
18
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
19
|
+
throw new MathError(op, "Operands must be finite numbers");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Operation } from "./Operation.js";
|
|
2
|
+
|
|
3
|
+
export class MathError extends Error {
|
|
4
|
+
readonly operation: Operation;
|
|
5
|
+
|
|
6
|
+
constructor(operation: Operation, message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "MathError";
|
|
9
|
+
this.operation = operation;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Operation = "add" | "subtract";
|