diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index ec091b5..a3387bf 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -143,8 +143,12 @@ jobs: echo "build=true" >> $GITHUB_OUTPUT fi + # The web's whole verification: the type check, then its unit tests. Both in the + # job `build` already needs, so a red web test blocks the image like any other + # lane (rule 177). The unit tests arrived with #5166; before them the web copy of + # the note grammar, mirrored in Rust and Python, was guarded by nothing. typecheck: - name: TypeScript typecheck + name: Web typecheck and unit tests if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: @@ -160,6 +164,10 @@ jobs: run: npx vue-tsc --noEmit working-directory: frontend + - name: Unit tests + run: npm test + working-directory: frontend + lint: name: Python lint if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 007051a..c1a61be 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -89,7 +89,7 @@ jobs: # bundling. No `always()` or `continue-on-error` anywhere on this path: a skipped # or failed verify must leave both publishers skipped. verify: - name: Clippy, tests and rustfmt + name: Web tests, clippy, Rust tests and rustfmt needs: [decide] if: needs.decide.outputs.build == 'true' runs-on: python-ci @@ -105,6 +105,13 @@ jobs: run: npm ci && npm run build working-directory: frontend + # The web's unit tests, HERE as well as in ci.yml. The installers embed this + # frontend, and ci.yml's verdict is invisible to this workflow — run there only, + # a red web test would still let both installers publish (rule 177). + - name: Web unit tests + run: npm test + working-directory: frontend + # --locked on the FIRST cargo invocation is the lockfile gate: it fails the # run if Cargo.toml and the committed Cargo.lock disagree, instead of silently # re-resolving (issue 2102). Both publishing jobs need this job, so neither diff --git a/android/app/src/test/java/com/fabledsword/inkwell/ui/DerivedTintTest.kt b/android/app/src/test/java/com/fabledsword/inkwell/ui/DerivedTintTest.kt index 9d954ef..1b135a4 100644 --- a/android/app/src/test/java/com/fabledsword/inkwell/ui/DerivedTintTest.kt +++ b/android/app/src/test/java/com/fabledsword/inkwell/ui/DerivedTintTest.kt @@ -7,12 +7,10 @@ import org.junit.Test /** * Pins the derived-colour rule against `frontend/src/notes/colors.ts`. * - * These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair - * has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc - * --noEmit` and nothing else), so if these values drift, nothing on that surface will - * say so and a tag will simply be a different colour on the phone than in the browser. - * The same names and hashes are written into colors.ts as a comment; changing either - * side means changing both and re-checking here. + * These are not tests of Kotlin — they pin the pair. The web runs the same values + * from `core/testdata/grammar.json` (`tint`) in `notes/grammar.test.ts`; this file + * writes them out by hand, so changing the hash or the key order means changing the + * fixture and this file together, or one of the two suites goes red. * * SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and * the resolution order that chose between a picked colour, a tag's and a generated one diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index af96e6a..92af128 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -770,4 +770,78 @@ mod tests { assert_eq!(touched, body); assert_eq!(extract_items(&touched), items); } + + // ── the shared fixture ─────────────────────────────────────────────────── + // + // core/testdata/grammar.json is the one set of cases the server (pytest), the web + // (vitest) and this file all run. The cases above stay as this file's own + // reasoning; these are the ones the other languages have agreed to. + + fn fixture() -> serde_json::Value { + serde_json::from_str(include_str!("../../testdata/grammar.json")) + .expect("grammar.json parses") + } + + fn strings(v: &serde_json::Value) -> Vec { + v.as_array() + .expect("an array") + .iter() + .map(|s| s.as_str().expect("a string").to_string()) + .collect() + } + + #[test] + fn fixture_task_lines() { + for case in fixture()["task_lines"].as_array().unwrap() { + let line = case["line"].as_str().unwrap(); + let got: Vec<(String, bool)> = extract_items(line) + .into_iter() + .map(|i| (i.text, i.checked)) + .collect(); + let want: Vec<(String, bool)> = match &case["item"] { + serde_json::Value::Null => Vec::new(), + item => vec![( + item["text"].as_str().unwrap().to_string(), + item["checked"].as_bool().unwrap(), + )], + }; + assert_eq!(got, want, "line {line:?}"); + } + } + + #[test] + fn fixture_rendered_items() { + for case in fixture()["rendered_items"].as_array().unwrap() { + let text = case["text"].as_str().unwrap(); + let checked = case["checked"].as_bool().unwrap(); + assert_eq!(render_item(text, checked), case["line"].as_str().unwrap()); + } + } + + #[test] + fn fixture_tags() { + for case in fixture()["tags"].as_array().unwrap() { + let body = case["body"].as_str().unwrap(); + assert_eq!(extract_tags(body), strings(&case["tags"]), "body {body:?}"); + } + } + + #[test] + fn fixture_lifts() { + for case in fixture()["lifts"].as_array().unwrap() { + let body = case["body"].as_str().unwrap(); + let (standalone, inline, lifted) = lift_standalone_tags(body); + assert_eq!( + standalone, + strings(&case["standalone"]), + "standalone, body {body:?}" + ); + assert_eq!(inline, strings(&case["inline"]), "inline, body {body:?}"); + assert_eq!( + lifted, + case["lifted"].as_str().unwrap(), + "lifted, body {body:?}" + ); + } + } } diff --git a/core/testdata/grammar.json b/core/testdata/grammar.json new file mode 100644 index 0000000..8e83faa --- /dev/null +++ b/core/testdata/grammar.json @@ -0,0 +1,88 @@ +{ + "_about": [ + "The cases every implementation of the note grammar is tested against.", + "One file, read by four test suites: core (derive.rs), the server (checklist.py, tags.py), the web (markdown.ts, colors.ts) and, for the tint, Android (DerivedTint.kt, which pins the same values by hand).", + "A difference between any two implementations is a note that changes shape when it syncs. Add a case here, not to one suite.", + "Lives under core/ because core is the definition, and because core/ is in the desktop, Android and server file sets, so changing a case reruns every suite that reads it.", + "Tag cases are plain text on purpose. The web's renderer lets `code` and **bold** claim a `#` first, which the derivers do not; that is a rendering choice, not grammar, and is tested where it lives." + ], + + "task_lines": [ + { "line": "- [ ] milk", "item": { "checked": false, "text": "milk" } }, + { "line": "- [x] milk", "item": { "checked": true, "text": "milk" } }, + { "line": "- [X] milk", "item": { "checked": true, "text": "milk" } }, + { "line": "* [ ] star bullet", "item": { "checked": false, "text": "star bullet" } }, + { "line": " - [ ] indented", "item": { "checked": false, "text": "indented" } }, + { "line": "\t- [ ] tab indent", "item": { "checked": false, "text": "tab indent" } }, + { "line": "- [ ] two spaces after the bullet", "item": { "checked": false, "text": "two spaces after the bullet" } }, + { "line": "- [ ]", "item": { "checked": false, "text": "" } }, + { "line": "- [ ] ", "item": { "checked": false, "text": "" } }, + { "line": "- [ ] trailing space kept ", "item": { "checked": false, "text": "trailing space kept " } }, + { "line": "- [ ]x", "item": null }, + { "line": "-[ ] x", "item": null }, + { "line": "+ [ ] plus bullet", "item": null }, + { "line": "- [y] x", "item": null }, + { "line": "- [ ] x", "item": null }, + { "line": "text - [ ] x", "item": null }, + { "line": "- milk", "item": null }, + { "line": "", "item": null } + ], + + "rendered_items": [ + { "text": "milk", "checked": false, "line": "- [ ] milk" }, + { "text": "milk", "checked": true, "line": "- [x] milk" }, + { "text": "", "checked": false, "line": "- [ ]" }, + { "text": "", "checked": true, "line": "- [x]" } + ], + + "tags": [ + { "body": "#todo buy milk", "tags": ["todo"] }, + { "body": "buy #milk and #eggs", "tags": ["milk", "eggs"] }, + { "body": "#Todo and #todo", "tags": ["Todo"] }, + { "body": "line one\n#two", "tags": ["two"] }, + { "body": "#tag, and #more.", "tags": ["tag", "more"] }, + { "body": "#x-y_z9", "tags": ["x-y_z9"] }, + { "body": "#café #über", "tags": ["café", "über"] }, + { "body": "(#todo)", "tags": ["todo"] }, + { "body": "end.#tag", "tags": ["tag"] }, + { "body": "#tag#more", "tags": ["tag"] }, + { "body": "a#b", "tags": [] }, + { "body": "x-#tag", "tags": [] }, + { "body": "##x", "tags": [] }, + { "body": "#1st", "tags": [] }, + { "body": "#2024", "tags": [] }, + { "body": "#_x", "tags": [] }, + { "body": "# heading", "tags": [] }, + { "body": "see https://x.com/page#frag", "tags": [] }, + { "body": "", "tags": [] } + ], + + "lifts": [ + { "body": "#todo\nreorganize the homepage", "standalone": ["todo"], "inline": [], "lifted": "reorganize the homepage" }, + { "body": "buy milk #grocery", "standalone": [], "inline": ["grocery"], "lifted": "buy milk #grocery" }, + { "body": "first\n\n#todo\n\nsecond", "standalone": ["todo"], "inline": [], "lifted": "first\n\nsecond" }, + { "body": "#todo\ncall #todo later", "standalone": [], "inline": ["todo"], "lifted": "call #todo later" }, + { "body": "#a #b", "standalone": [], "inline": ["a", "b"], "lifted": "#a #b" }, + { "body": "```\n# comment #todo\n```", "standalone": [], "inline": ["todo"], "lifted": "```\n# comment #todo\n```" }, + { "body": "(#todo)\nnotes", "standalone": [], "inline": ["todo"], "lifted": "(#todo)\nnotes" } + ], + + "tint": { + "keys": ["red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"], + "hashes": [ + { "id": "00000000-0000-0000-0000-000000000000", "hash": 3192360657, "color": "purple" }, + { "id": "11111111-1111-1111-1111-111111111111", "hash": 1031130113, "color": "blue" }, + { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "hash": 4043892016, "color": "orange" }, + { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "hash": 1533351232, "color": "orange" } + ], + "names": [ + { "name": "todo", "color": "pink" }, + { "name": "grocery", "color": "blue" }, + { "name": "work", "color": "green" }, + { "name": "home", "color": "gray" }, + { "name": "ideas", "color": "green" }, + { "name": "reading", "color": "gray" }, + { "name": "urgent", "color": "red" } + ] + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c057770..600d56a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "tailwindcss": "^3.4.0", "typescript": "~5.5.0", "vite": "^5.4.0", + "vitest": "^3.2.7", "vue-tsc": "^2.1.0" } }, @@ -640,9 +641,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -657,9 +655,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -674,9 +669,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -691,9 +683,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -708,9 +697,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -725,9 +711,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -742,9 +725,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -759,9 +739,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -776,9 +753,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -793,9 +767,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -810,9 +781,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -827,9 +795,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -844,9 +809,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -937,6 +899,22 @@ "win32" ] }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -958,6 +936,123 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1162,6 +1257,15 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.5.4", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", @@ -1289,6 +1393,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1320,6 +1433,31 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1394,6 +1532,32 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1437,6 +1601,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1492,6 +1662,15 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1692,6 +1871,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1712,6 +1897,12 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1761,6 +1952,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1852,6 +2049,21 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2221,6 +2433,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2230,6 +2448,30 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2327,6 +2569,18 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2375,6 +2629,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2507,6 +2788,112 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2592,6 +2979,22 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 1e1a49e..b8e956f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "pinia": "^2.2.0", @@ -20,6 +21,7 @@ "tailwindcss": "^3.4.0", "typescript": "~5.5.0", "vite": "^5.4.0", + "vitest": "^3.2.7", "vue-tsc": "^2.1.0" } } diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index 971b66d..689bb66 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -169,9 +169,9 @@ export const NOTE_COLOR_LABELS: Record = { // three implementations, and the same reason: a value that disagrees across surfaces // is a bug you cannot unsee and cannot explain. // -// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO -// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`. -// If you change anything here, check it against the fixture by hand. +// Both sides are pinned to the same values: `core/testdata/grammar.json` (`tint`), +// which `notes/grammar.test.ts` reads, and DerivedTintTest.kt, which writes the same +// numbers out by hand. Change the hash or the key order and both go red. export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter( (key) => key !== "default", ); @@ -235,22 +235,8 @@ export function resolveLabelColor(label: { name: string; color?: string | null } return derivedTint(label.name.toLowerCase()); } -// Fixture — the same names and expected keys the Kotlin test asserts. Kept here as -// prose because there is nowhere on this side to assert it. If you change the hash or -// the key order, these must still hold on BOTH surfaces. -// -// The raw hash, over four UUIDs — ids no longer pick a colour, but they are what the -// hash itself is pinned by and the Kotlin test still asserts them: -// -// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple -// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue -// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange -// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange -// -// And the live path — a label, hashing its lowercased NAME: -// -// todo -> pink grocery -> blue work -> green home -> gray -// ideas -> green reading -> gray urgent -> red +// The fixture (four UUID hashes and seven tag names) lives in +// core/testdata/grammar.json under `tint`. // // Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable // and it is not a bug: colour hints that two tags are distinct, it never claims two diff --git a/frontend/src/notes/grammar.test.ts b/frontend/src/notes/grammar.test.ts new file mode 100644 index 0000000..5b34eb4 --- /dev/null +++ b/frontend/src/notes/grammar.test.ts @@ -0,0 +1,54 @@ +// The web's half of the shared grammar fixture (core/testdata/grammar.json). +// +// The same cases run against the core (derive.rs) and the server (checklist.py, +// tags.py). Until this file the web copy had no mechanical guard at all — the +// frontend's only CI lane was `vue-tsc --noEmit` — so a drift here showed up as a +// checklist or a tag colour that differed in the browser and nowhere else. +import { describe, expect, it } from "vitest"; +import fixture from "../../../core/testdata/grammar.json"; +import { DERIVED_TINT_KEYS, derivedTint, resolveLabelColor, tintHash } from "./colors"; +import { parseInline, parseTaskLine, renderTaskLine } from "./markdown"; + +describe("checklist lines", () => { + it.each(fixture.task_lines)("$line", ({ line, item }) => { + expect(parseTaskLine(line)).toEqual(item); + }); + + it.each(fixture.rendered_items)("renders $line", ({ text, checked, line }) => { + expect(renderTaskLine(text, checked)).toBe(line); + }); +}); + +describe("tags", () => { + // The renderer finds tags; it does not collect them. Collected here line by line + // and deduped the way the derivers dedupe — first spelling wins, case-insensitively. + function tagsOf(body: string): string[] { + const out: string[] = []; + for (const line of body.split("\n")) { + for (const token of parseInline(line)) { + if (token.type !== "tag") continue; + if (!out.some((t) => t.toLowerCase() === token.value.toLowerCase())) out.push(token.value); + } + } + return out; + } + + it.each(fixture.tags)("$body", ({ body, tags }) => { + expect(tagsOf(body)).toEqual(tags); + }); +}); + +describe("derived tag colour", () => { + it("uses the same key order as Android", () => { + expect([...DERIVED_TINT_KEYS]).toEqual(fixture.tint.keys); + }); + + it.each(fixture.tint.hashes)("hashes $id", ({ id, hash, color }) => { + expect(tintHash(id)).toBe(hash); + expect(derivedTint(id)).toBe(color); + }); + + it.each(fixture.tint.names)("colours #$name", ({ name, color }) => { + expect(resolveLabelColor({ name })).toBe(color); + }); +}); diff --git a/frontend/src/stores/titles.test.ts b/frontend/src/stores/titles.test.ts new file mode 100644 index 0000000..14704a9 --- /dev/null +++ b/frontend/src/stores/titles.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +vi.mock("../adapters", () => ({ repo: { notes: { titles: vi.fn() } } })); + +import { repo } from "../adapters"; +import { useTitlesStore } from "./titles"; + +describe("the palette's note titles", () => { + beforeEach(() => setActivePinia(createPinia())); + + // #5165: the list loaded once per session, so a note written after the palette + // first opened could not be found by name until the page reloaded. + it("sees a note written since the last open", async () => { + vi.mocked(repo.notes.titles) + .mockResolvedValueOnce([{ id: "a", title: "Groceries" }]) + .mockResolvedValueOnce([ + { id: "a", title: "Groceries" }, + { id: "b", title: "Trip" }, + ]); + const titles = useTitlesStore(); + + await titles.refresh(); + expect(titles.items.map((t) => t.title)).toEqual(["Groceries"]); + + await titles.refresh(); + expect(titles.items.map((t) => t.title)).toEqual(["Groceries", "Trip"]); + }); + + it("keeps the last list when a refresh fails", async () => { + vi.mocked(repo.notes.titles) + .mockResolvedValueOnce([{ id: "a", title: "Groceries" }]) + .mockRejectedValueOnce(new Error("offline")); + const titles = useTitlesStore(); + + await titles.refresh(); + await expect(titles.refresh()).rejects.toThrow("offline"); + expect(titles.items.map((t) => t.title)).toEqual(["Groceries"]); + }); +}); diff --git a/tests/test_grammar_fixture.py b/tests/test_grammar_fixture.py new file mode 100644 index 0000000..a4ee6e7 --- /dev/null +++ b/tests/test_grammar_fixture.py @@ -0,0 +1,43 @@ +"""The server's half of the shared grammar fixture (core/testdata/grammar.json). + +The same cases run against the core (derive.rs) and the web (markdown.ts). A case +that passes in one language and fails in another is a note that changes shape when +it syncs — a checklist that stops being one, or a tag that is a label on the phone +and plain text on the server. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from inkwell.notes.checklist import parse_items, render_item +from inkwell.notes.tags import parse_tags, split_body_tags + +FIXTURE = json.loads( + (Path(__file__).resolve().parents[1] / "core" / "testdata" / "grammar.json").read_text(encoding="utf-8") +) + + +@pytest.mark.parametrize("case", FIXTURE["task_lines"], ids=lambda c: repr(c["line"])) +def test_task_lines(case): + got = [(i.text, i.checked) for i in parse_items(case["line"])] + want = [] if case["item"] is None else [(case["item"]["text"], case["item"]["checked"])] + assert got == want + + +@pytest.mark.parametrize("case", FIXTURE["rendered_items"], ids=lambda c: repr(c["line"])) +def test_rendered_items(case): + assert render_item(case["text"], case["checked"]) == case["line"] + + +@pytest.mark.parametrize("case", FIXTURE["tags"], ids=lambda c: repr(c["body"])) +def test_tags(case): + assert parse_tags(case["body"]) == case["tags"] + + +@pytest.mark.parametrize("case", FIXTURE["lifts"], ids=lambda c: repr(c["body"])) +def test_lifts(case): + standalone, inline, lifted = split_body_tags(case["body"]) + assert (standalone, inline, lifted) == (case["standalone"], case["inline"], case["lifted"])