paperlint 3.1.0 → 3.1.2
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/.github/workflows/ci.yml +28 -41
- package/.github/workflows/dependabot-automerge.yml +1 -1
- package/CLAUDE.md +21 -105
- package/CONTRIBUTING.md +23 -25
- package/README.md +11 -7
- package/bin/paperlint.test.mjs +45 -0
- package/dist/adapters/node/files.io.d.ts +2 -0
- package/dist/adapters/node/files.io.d.ts.map +1 -1
- package/dist/adapters/node/files.io.js +3 -2
- package/dist/adapters/node/files.io.js.map +1 -1
- package/dist/adapters/pdfjs/fill.d.ts +15 -4
- package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
- package/dist/adapters/pdfjs/fill.js +31 -21
- package/dist/adapters/pdfjs/fill.js.map +1 -1
- package/dist/adapters/references/clock.io.d.ts +5 -0
- package/dist/adapters/references/clock.io.d.ts.map +1 -0
- package/dist/adapters/references/clock.io.js +7 -0
- package/dist/adapters/references/clock.io.js.map +1 -0
- package/dist/adapters/references/index.d.ts +24 -2
- package/dist/adapters/references/index.d.ts.map +1 -1
- package/dist/adapters/references/index.js +131 -12
- package/dist/adapters/references/index.js.map +1 -1
- package/dist/adapters/references/pool.d.ts +8 -0
- package/dist/adapters/references/pool.d.ts.map +1 -0
- package/dist/adapters/references/pool.js +19 -0
- package/dist/adapters/references/pool.js.map +1 -0
- package/dist/build.d.ts +14 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +17 -11
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +27 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +71 -55
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +4 -3
- package/dist/doctor.js.map +1 -1
- package/dist/domain/lookup-cache.d.ts +75 -0
- package/dist/domain/lookup-cache.d.ts.map +1 -0
- package/dist/domain/lookup-cache.js +137 -0
- package/dist/domain/lookup-cache.js.map +1 -0
- package/dist/domain/result.d.ts +0 -2
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +0 -4
- package/dist/domain/result.js.map +1 -1
- package/dist/hooks-settings.d.ts +8 -5
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +32 -32
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +8 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/latex-log.d.ts +0 -2
- package/dist/latex-log.d.ts.map +1 -1
- package/dist/latex-log.js +7 -13
- package/dist/latex-log.js.map +1 -1
- package/dist/link-skills.d.ts +15 -2
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +36 -20
- package/dist/link-skills.js.map +1 -1
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +6 -4
- package/dist/paper-files.js.map +1 -1
- package/dist/pdf-facts.d.ts +11 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +16 -18
- package/dist/pdf-facts.js.map +1 -1
- package/dist/pdf-geometry.js +1 -1
- package/dist/pdf-geometry.js.map +1 -1
- package/dist/ports/check-references.d.ts +12 -2
- package/dist/ports/check-references.d.ts.map +1 -1
- package/dist/ports/check-references.js +0 -5
- package/dist/ports/check-references.js.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +3 -1
- package/dist/presets.js.map +1 -1
- package/dist/references.d.ts +11 -3
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +63 -15
- package/dist/references.js.map +1 -1
- package/dist/tex-requirements.d.ts +8 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +6 -5
- package/dist/tex-requirements.js.map +1 -1
- package/dist/toolchain.d.ts +3 -2
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +4 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +11 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/prior-art/README.md +11 -10
- package/docs/prior-art/citation-checkers.md +174 -0
- package/docs/prior-art/test-tooling.md +7 -30
- package/docs/references.md +126 -0
- package/docs/testing.md +118 -0
- package/docs/toolchain.md +6 -4
- package/eslint-rules/install-path-literals.harness.mjs +17 -1
- package/eslint-rules/latex-language.harness.mjs +3 -3
- package/eslint-rules/latex-language.mjs +12 -9
- package/eslint-rules/latex-language.test.mjs +200 -0
- package/eslint-rules/paper-research-question.harness.mjs +73 -7
- package/eslint-rules/paper-stages.harness.mjs +9 -14
- package/eslint-rules/paper-stages.test.mjs +135 -0
- package/eslint-rules/paper-typography.mjs +9 -2
- package/eslint-rules/paper-typography.test.mjs +146 -1
- package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
- package/eslint-rules/review-frontmatter.mjs +2 -1
- package/eslint-rules/review-frontmatter.test.mjs +37 -1
- package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
- package/eslint-rules/tex-build.mjs +13 -13
- package/eslint.config.mjs +6 -2
- package/hooks/hooks.harness.mjs +6 -11
- package/hooks/paper-edit-guard.hook.mjs +2 -2
- package/hooks/paper-edit-guard.test.mjs +61 -0
- package/hooks/paper-skills-nudge.hook.mjs +2 -2
- package/hooks/paper-skills-nudge.test.mjs +38 -0
- package/hooks/paper-status-gates.test.mjs +42 -0
- package/lib/agent-cli-version.harness.mjs +7 -0
- package/lib/check.d.mts +17 -0
- package/lib/check.mjs +50 -0
- package/lib/check.test.ts +67 -0
- package/lib/markdown.mjs +16 -69
- package/lib/markdown.test.mjs +246 -0
- package/lib/paper-config.harness.mjs +28 -9
- package/lib/paper-config.mjs +7 -2
- package/lib/skill-checks.mjs +18 -46
- package/lib/skill-checks.test.mjs +297 -0
- package/lib/skill-corpus.mjs +11 -13
- package/lib/skill-corpus.test.mjs +54 -0
- package/lib/skill-trigger-cases.harness.mjs +57 -4
- package/package.json +6 -5
- package/scripts/check.harness.mjs +4 -10
- package/scripts/check.mjs +45 -42
- package/scripts/check.test.mjs +104 -0
- package/scripts/coverage-config.test.ts +406 -0
- package/scripts/eslint-report-guard.test.mjs +48 -0
- package/scripts/harness-api.frozen.json +0 -2
- package/scripts/layer-legacy-frozen.d.mts +9 -1
- package/scripts/layer-legacy-frozen.mjs +28 -11
- package/scripts/layer-legacy-frozen.test.ts +143 -1
- package/scripts/rules-are-content-only.harness.mjs +3 -11
- package/scripts/rules-are-content-only.mjs +29 -19
- package/scripts/rules-are-content-only.test.mjs +61 -0
- package/scripts/rules-see-files.mjs +11 -9
- package/scripts/rules-see-files.test.mjs +71 -0
- package/scripts/test.mjs +44 -0
- package/scripts/test.test.mjs +52 -0
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
- package/skills/grade-paper-writing/prose-lint.mjs +31 -12
- package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
- package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
- package/skills/paper-pipeline/scripts/README.md +13 -132
- package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
- package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
- package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
- package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
- package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
- package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
- package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
- package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
- package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
- package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
- package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
- package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
- package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
- package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
- package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
- package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
- package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
- package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
- package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
- package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
- package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
- package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
- package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
- package/skills/paper-pipeline/scripts/status.mjs +2 -2
- package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
- package/skills/render-paper/checkers-requirements.txt +30 -19
- package/skills/render-paper/ensure-checkers.sh +20 -4
- package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
- package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
- package/skills/tighten-paper/structure.test.mjs +116 -0
- package/skills/verify-citations/SKILL.md +30 -14
- package/skills/verify-citations/SKILL.md.spec.ts +29 -13
- package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
- package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
- package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
- package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
- package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
- package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
- package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
- package/src/adapters/banal/failure.test.ts +70 -1
- package/src/adapters/banal/index.test.ts +16 -0
- package/src/adapters/banal/output.test.ts +5 -0
- package/src/adapters/curl/download.test.ts +43 -1
- package/src/adapters/memory/index.test.ts +25 -0
- package/src/adapters/node/files.io.ts +3 -2
- package/src/adapters/node/files.test.ts +13 -1
- package/src/adapters/node/process.test.ts +38 -0
- package/src/adapters/pdfjs/fill.test.ts +34 -0
- package/src/adapters/pdfjs/fill.ts +44 -25
- package/src/adapters/references/clock.io.ts +8 -0
- package/src/adapters/references/index.test.ts +560 -0
- package/src/adapters/references/index.ts +203 -19
- package/src/adapters/references/pool.test.ts +31 -0
- package/src/adapters/references/pool.ts +24 -0
- package/src/build-engine.harness.mjs +3 -7
- package/src/build-engine.test.ts +137 -0
- package/src/build.harness.mjs +5 -9
- package/src/build.test.ts +362 -0
- package/src/build.ts +23 -12
- package/src/cli.harness.mjs +10 -17
- package/src/cli.test.ts +584 -0
- package/src/cli.ts +112 -66
- package/src/doctor.harness.mjs +3 -8
- package/src/doctor.test.ts +64 -0
- package/src/doctor.ts +12 -4
- package/src/domain/lookup-cache.test.ts +178 -0
- package/src/domain/lookup-cache.ts +202 -0
- package/src/domain/result.test.ts +4 -0
- package/src/domain/result.ts +0 -5
- package/src/engine.harness.mjs +27 -7
- package/src/facts-file.test.ts +110 -0
- package/src/hooks-settings.harness.mjs +43 -13
- package/src/hooks-settings.ts +41 -35
- package/src/init.test.ts +308 -5
- package/src/init.ts +3 -3
- package/src/latex-log.harness.mjs +32 -7
- package/src/latex-log.ts +6 -13
- package/src/latex-loop.harness.mjs +8 -7
- package/src/link-skills.harness.mjs +3 -8
- package/src/link-skills.test.ts +105 -0
- package/src/link-skills.ts +75 -21
- package/src/new-paper.harness.mjs +3 -7
- package/src/new-paper.test.ts +31 -1
- package/src/paper-files.test.ts +12 -0
- package/src/paper-files.ts +9 -5
- package/src/paper-settings.test.ts +20 -0
- package/src/pdf-facts.harness.mjs +3 -6
- package/src/pdf-facts.test.ts +144 -0
- package/src/pdf-facts.ts +17 -19
- package/src/pdf-geometry.harness.mjs +3 -7
- package/src/pdf-geometry.ts +1 -1
- package/src/ports/check-references.ts +16 -2
- package/src/presets.test.ts +2 -0
- package/src/presets.ts +7 -1
- package/src/references.test.ts +257 -10
- package/src/references.ts +93 -17
- package/src/rules-config.harness.mjs +10 -8
- package/src/structure.harness.mjs +3 -7
- package/src/tex-requirements.harness.mjs +34 -7
- package/src/tex-requirements.ts +12 -6
- package/src/toolchain.harness.mjs +3 -7
- package/src/toolchain.test.ts +280 -0
- package/src/toolchain.ts +8 -7
- package/src/venue-rules.test.ts +60 -0
- package/src/venue-rules.ts +13 -11
- package/action.mutations.mjs +0 -162
- package/eslint-rules/install-path-literals.mutations.mjs +0 -62
- package/eslint-rules/latex-language.mutations.mjs +0 -196
- package/eslint-rules/paper-research-question.mutations.mjs +0 -127
- package/eslint-rules/paper-stages.mutations.mjs +0 -81
- package/eslint-rules/papers.mutations.mjs +0 -186
- package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
- package/eslint-rules/tex-build.mutations.mjs +0 -258
- package/hooks/hooks.mutations.mjs +0 -337
- package/lib/agent-cli-version.mutations.mjs +0 -109
- package/lib/mutation-driver.harness.mjs +0 -227
- package/lib/mutation-driver.mjs +0 -397
- package/lib/mutation-driver.mutations.mjs +0 -68
- package/lib/paper-config.mutations.mjs +0 -143
- package/lib/skill-trigger-cases.mutations.mjs +0 -65
- package/scripts/check.mutations.mjs +0 -110
- package/scripts/exclusive.mjs +0 -138
- package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
- package/scripts/mutation-batteries-frozen.mjs +0 -238
- package/scripts/mutation-batteries.frozen.json +0 -108
- package/scripts/rules-are-content-only.mutations.mjs +0 -81
- package/scripts/rules-see-files.mutations.mjs +0 -131
- package/scripts/run-mutations.mjs +0 -100
- package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
- package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
- package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
- package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
- package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
- package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
- package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
- package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
- package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
- package/skills/skill-contract.mutations.mjs +0 -179
- package/src/build.mutations.mjs +0 -196
- package/src/cli.mutations.mjs +0 -414
- package/src/doctor.mutations.mjs +0 -175
- package/src/hooks-settings.mutations.mjs +0 -99
- package/src/link-skills.mutations.mjs +0 -140
- package/src/new-paper.mutations.mjs +0 -65
- package/src/structure.mutations.mjs +0 -83
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-cites.mjs's network layer — the four registries, the DOI authority and NVD — driven
|
|
3
|
+
* in-process with a fake `fetch`, so every transport outcome is produced on purpose and nothing
|
|
4
|
+
* touches the network. Each case compares the whole verdict AND the exact requests made: a
|
|
5
|
+
* resolver that asks the wrong URL is as broken as one that misreads the answer.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
createBreaker,
|
|
11
|
+
verifyCitationLive,
|
|
12
|
+
wouldAsk,
|
|
13
|
+
} from "./verify-cites.mjs";
|
|
14
|
+
|
|
15
|
+
const json = (status, body) =>
|
|
16
|
+
new Response(body === undefined ? null : JSON.stringify(body), { status });
|
|
17
|
+
const text = (status, body) => new Response(body, { status });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A fake `fetch`: `routes` maps a URL prefix to a response factory (or an Error to throw). The
|
|
21
|
+
* longest matching prefix wins; an unrouted URL fails the test. Returns the URLs asked, in order.
|
|
22
|
+
*/
|
|
23
|
+
function fakeFetch(routes) {
|
|
24
|
+
const calls = [];
|
|
25
|
+
vi.stubGlobal("fetch", async (url, init) => {
|
|
26
|
+
calls.push(url);
|
|
27
|
+
const hit = Object.keys(routes)
|
|
28
|
+
.filter((p) => url.startsWith(p))
|
|
29
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
30
|
+
if (hit === undefined) throw new Error(`unrouted request: ${url}`);
|
|
31
|
+
const r = routes[hit];
|
|
32
|
+
if (r instanceof Error) throw r;
|
|
33
|
+
return r(init?.signal);
|
|
34
|
+
});
|
|
35
|
+
return calls;
|
|
36
|
+
}
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const CR = "https://api.crossref.org/works";
|
|
43
|
+
const OA = "https://api.openalex.org/works";
|
|
44
|
+
const S2 = "https://api.semanticscholar.org/graph/v1/paper";
|
|
45
|
+
const AX = "https://export.arxiv.org/api/query";
|
|
46
|
+
const DOI = "https://doi.org/api/handles/";
|
|
47
|
+
const NVD = "https://services.nvd.nist.gov/rest/json/cves/2.0";
|
|
48
|
+
const TITLE = "Attention Is All You Need";
|
|
49
|
+
const feed = (title, year) =>
|
|
50
|
+
`<feed><entry><id>http://arxiv.org/abs/1706.03762v1</id><published>${year}-06-12</published><title>${title}</title></entry></feed>`;
|
|
51
|
+
|
|
52
|
+
test("a DOI every registry resolves to the claimed paper, and the authority knows: true", async () => {
|
|
53
|
+
const calls = fakeFetch({
|
|
54
|
+
[CR]: () =>
|
|
55
|
+
json(200, {
|
|
56
|
+
message: { title: [TITLE], issued: { "date-parts": [[2017]] } },
|
|
57
|
+
}),
|
|
58
|
+
[OA]: () =>
|
|
59
|
+
json(200, { id: "W1", display_name: TITLE, publication_year: 2017 }),
|
|
60
|
+
[S2]: () => json(200, { title: TITLE, year: 2017 }),
|
|
61
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
62
|
+
});
|
|
63
|
+
const cite = { id: "v", doi: "10.1/x", title: TITLE, year: 2017 };
|
|
64
|
+
expect({ result: await verifyCitationLive(cite), calls }).toMatchSnapshot();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a DOI nobody knows, and the authority says it does not exist: false", async () => {
|
|
68
|
+
const calls = fakeFetch({
|
|
69
|
+
[CR]: () => json(404),
|
|
70
|
+
[OA]: () => json(404),
|
|
71
|
+
[S2]: () => json(404),
|
|
72
|
+
[DOI]: () => json(404),
|
|
73
|
+
});
|
|
74
|
+
const result = await verifyCitationLive({ id: "f", doi: "10.1/nope" });
|
|
75
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("every registry down (429, 503, 403, a thrown error): unresolvable, never false", async () => {
|
|
79
|
+
const calls = fakeFetch({
|
|
80
|
+
[CR]: () => json(429),
|
|
81
|
+
[OA]: () => json(503),
|
|
82
|
+
[S2]: () => json(403),
|
|
83
|
+
[DOI]: new TypeError("fetch failed"),
|
|
84
|
+
});
|
|
85
|
+
const result = await verifyCitationLive({
|
|
86
|
+
id: "u",
|
|
87
|
+
doi: "10.1/x",
|
|
88
|
+
title: TITLE,
|
|
89
|
+
});
|
|
90
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("records with fields missing: no title, a subtitle, a year only in `published`, no id", async () => {
|
|
94
|
+
const calls = fakeFetch({
|
|
95
|
+
[CR]: () =>
|
|
96
|
+
json(200, {
|
|
97
|
+
// No title and a subtitle that is not the claimed title's: no match, so the lookups go on
|
|
98
|
+
// (a match would end them) and every registry's missing fields are read.
|
|
99
|
+
message: {
|
|
100
|
+
subtitle: ["Unrelated Words About Gardening"],
|
|
101
|
+
published: { "date-parts": [[2017]] },
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
[OA]: () => json(200, { display_name: TITLE }), // no `id` → no record
|
|
105
|
+
[S2]: () => json(200, { title: null }), // a null title → no record
|
|
106
|
+
[DOI]: () => json(200, { responseCode: "1" }), // not a number → unknown
|
|
107
|
+
});
|
|
108
|
+
const result = await verifyCitationLive({
|
|
109
|
+
id: "m",
|
|
110
|
+
doi: "10.1/x",
|
|
111
|
+
title: TITLE,
|
|
112
|
+
});
|
|
113
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("an arXiv id: Semantic Scholar by arXiv id, the arXiv API by id_list", async () => {
|
|
117
|
+
const calls = fakeFetch({
|
|
118
|
+
[S2]: () => json(200, { title: TITLE, year: 2017 }),
|
|
119
|
+
[AX]: () => text(200, feed(TITLE, 2017)),
|
|
120
|
+
});
|
|
121
|
+
const result = await verifyCitationLive({
|
|
122
|
+
id: "a",
|
|
123
|
+
arxiv: "1706.03762",
|
|
124
|
+
title: TITLE,
|
|
125
|
+
});
|
|
126
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("an arXiv id nobody has: the arXiv API's empty feed and S2's 404", async () => {
|
|
130
|
+
const calls = fakeFetch({
|
|
131
|
+
[S2]: () => json(404),
|
|
132
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
133
|
+
});
|
|
134
|
+
const result = await verifyCitationLive({ id: "n", arxiv: "9999.99999" });
|
|
135
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a title only: every registry's search endpoint, hits and all", async () => {
|
|
139
|
+
// The first three registries' hits carry the title with a year too far off to match, so the
|
|
140
|
+
// lookups go on (a match would end them) and every search endpoint's answer is read; arXiv's
|
|
141
|
+
// hit is the one that matches.
|
|
142
|
+
const calls = fakeFetch({
|
|
143
|
+
[`${CR}?`]: () =>
|
|
144
|
+
json(200, {
|
|
145
|
+
message: {
|
|
146
|
+
items: [{ title: [TITLE], issued: { "date-parts": [[1990]] } }, {}],
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
[`${OA}?`]: () =>
|
|
150
|
+
json(200, {
|
|
151
|
+
results: [{ display_name: TITLE, publication_year: 1990 }, {}],
|
|
152
|
+
}),
|
|
153
|
+
[`${S2}/search`]: () =>
|
|
154
|
+
json(200, { data: [{ title: TITLE, year: 1990 }, {}] }),
|
|
155
|
+
[AX]: () => text(200, feed(TITLE, 2017)),
|
|
156
|
+
});
|
|
157
|
+
const result = await verifyCitationLive({
|
|
158
|
+
id: "t",
|
|
159
|
+
title: TITLE,
|
|
160
|
+
year: 2017,
|
|
161
|
+
});
|
|
162
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a title search whose answers are empty or shapeless is a miss, not a match", async () => {
|
|
166
|
+
const calls = fakeFetch({
|
|
167
|
+
[`${CR}?`]: () => json(200, {}),
|
|
168
|
+
[`${OA}?`]: () => json(200, {}),
|
|
169
|
+
[`${S2}/search`]: () => json(200, {}),
|
|
170
|
+
[AX]: () => text(200, ""),
|
|
171
|
+
});
|
|
172
|
+
const result = await verifyCitationLive({ id: "e", title: TITLE });
|
|
173
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a title search that cannot reach any registry is unresolvable", async () => {
|
|
177
|
+
const abort = Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
178
|
+
const calls = fakeFetch({
|
|
179
|
+
[`${CR}?`]: () => json(500),
|
|
180
|
+
[`${OA}?`]: () => json(502),
|
|
181
|
+
[`${S2}/search`]: abort,
|
|
182
|
+
[AX]: () => text(503, ""),
|
|
183
|
+
});
|
|
184
|
+
const result = await verifyCitationLive({ id: "d", title: TITLE });
|
|
185
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("S2 and arXiv unreachable for an arXiv id", async () => {
|
|
189
|
+
const calls = fakeFetch({ [S2]: () => json(500), [AX]: () => text(500, "") });
|
|
190
|
+
const result = await verifyCitationLive({ id: "x", arxiv: "1706.03762" });
|
|
191
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("CVEs: found by count, found by list, not found, NVD down, and a malformed id", async () => {
|
|
195
|
+
const calls = fakeFetch({
|
|
196
|
+
[`${NVD}?cveId=CVE-2024-0001`]: () => json(200, { totalResults: 1 }),
|
|
197
|
+
[`${NVD}?cveId=CVE-2024-0002`]: () => json(200, { vulnerabilities: [{}] }),
|
|
198
|
+
[`${NVD}?cveId=CVE-2024-0003`]: () => json(404),
|
|
199
|
+
[`${NVD}?cveId=CVE-2024-0004`]: () => json(500),
|
|
200
|
+
[`${NVD}?cveId=CVE-2024-0005`]: () => json(200, {}),
|
|
201
|
+
});
|
|
202
|
+
const results = [];
|
|
203
|
+
for (const cve of [
|
|
204
|
+
"cve-2024-0001",
|
|
205
|
+
"CVE-2024-0002",
|
|
206
|
+
"CVE-2024-0003",
|
|
207
|
+
"CVE-2024-0004",
|
|
208
|
+
"CVE-2024-0005",
|
|
209
|
+
"CVE-24-1",
|
|
210
|
+
])
|
|
211
|
+
results.push(await verifyCitationLive({ id: cve, cve }));
|
|
212
|
+
expect({ results, calls }).toMatchSnapshot();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a second lookup is served from the cache; failures and a nonexistent DOI are not cached", async () => {
|
|
216
|
+
const cache = {};
|
|
217
|
+
// No registry confirms the DOI, so every lookup runs: a match would end them (tested below).
|
|
218
|
+
fakeFetch({
|
|
219
|
+
[CR]: () => json(404),
|
|
220
|
+
[OA]: () => json(503),
|
|
221
|
+
[S2]: () => json(404),
|
|
222
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
223
|
+
[NVD]: () => json(200, { totalResults: 1 }),
|
|
224
|
+
});
|
|
225
|
+
const cite = { id: "c", doi: "10.1/x", title: TITLE, cve: "CVE-2024-0001" };
|
|
226
|
+
const first = await verifyCitationLive(cite, { cache });
|
|
227
|
+
const again = fakeFetch({ [OA]: () => json(503) });
|
|
228
|
+
const second = await verifyCitationLive(cite, { cache });
|
|
229
|
+
assert.deepEqual(
|
|
230
|
+
{ same: second, keys: Object.keys(cache).sort(), again },
|
|
231
|
+
{
|
|
232
|
+
same: first,
|
|
233
|
+
// arXiv is not asked about a DOI at all, so it has nothing to cache.
|
|
234
|
+
keys: [
|
|
235
|
+
"crossref:doi:10.1/x",
|
|
236
|
+
"doi_authority:10.1/x",
|
|
237
|
+
"nvd:CVE-2024-0001",
|
|
238
|
+
"semantic_scholar:doi:10.1/x",
|
|
239
|
+
],
|
|
240
|
+
// Only the resolver that failed the first time is asked again.
|
|
241
|
+
again: ["https://api.openalex.org/works/doi:10.1%2Fx"],
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Guards: each resolver's cache key names the identifier that resolver ASKED. arXiv is asked by
|
|
247
|
+
// arXiv id even when the entry has a DOI; keyed by the DOI, a corrected arXiv id kept the old
|
|
248
|
+
// id's answer and was never asked.
|
|
249
|
+
test("a corrected arXiv id is asked again even when the DOI is unchanged", async () => {
|
|
250
|
+
const cache = {};
|
|
251
|
+
const routes = {
|
|
252
|
+
[CR]: () => json(404),
|
|
253
|
+
[OA]: () => json(404),
|
|
254
|
+
[S2]: () => json(404),
|
|
255
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
256
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
257
|
+
};
|
|
258
|
+
fakeFetch(routes);
|
|
259
|
+
await verifyCitationLive(
|
|
260
|
+
{ id: "c", doi: "10.1/x", arxiv: "2401.00001", title: TITLE },
|
|
261
|
+
{ cache },
|
|
262
|
+
);
|
|
263
|
+
const again = fakeFetch(routes);
|
|
264
|
+
await verifyCitationLive(
|
|
265
|
+
{ id: "c", doi: "10.1/x", arxiv: "2401.00002", title: TITLE },
|
|
266
|
+
{ cache },
|
|
267
|
+
);
|
|
268
|
+
assert.deepEqual(
|
|
269
|
+
again.filter((u) => u.startsWith(AX)),
|
|
270
|
+
[`${AX}?id_list=2401.00002&max_results=1`],
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Guards: NVD's "not found" must not be cached. A CVE cited before NVD indexes it would otherwise
|
|
275
|
+
// stay "fabricated" in the committed cache forever; only positive existence is kept, as for doi.org.
|
|
276
|
+
test("an NVD not-found answer is not cached, so a later build asks again", async () => {
|
|
277
|
+
const cache = {};
|
|
278
|
+
fakeFetch({ [NVD]: () => json(200, { totalResults: 0 }) });
|
|
279
|
+
await verifyCitationLive({ id: "c", cve: "CVE-2026-0001" }, { cache });
|
|
280
|
+
const again = fakeFetch({ [NVD]: () => json(200, { totalResults: 1 }) });
|
|
281
|
+
await verifyCitationLive({ id: "c", cve: "CVE-2026-0001" }, { cache });
|
|
282
|
+
assert.deepEqual(
|
|
283
|
+
{ asked: again.length, keys: Object.keys(cache) },
|
|
284
|
+
{ asked: 1, keys: ["nvd:CVE-2026-0001"] },
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Guards: a title-only cache key must keep punctuation that names a different work. A shared key
|
|
289
|
+
// for `C` and `C++` would answer the second from the first's registry responses, no request made.
|
|
290
|
+
test("title-only keys keep punctuation: a C++ title is not served the C title's cached answers", async () => {
|
|
291
|
+
const cache = {};
|
|
292
|
+
const miss = () =>
|
|
293
|
+
json(200, { message: { items: [] }, results: [], data: [] });
|
|
294
|
+
fakeFetch({
|
|
295
|
+
[CR]: miss,
|
|
296
|
+
[OA]: miss,
|
|
297
|
+
[S2]: miss,
|
|
298
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
299
|
+
});
|
|
300
|
+
await verifyCitationLive(
|
|
301
|
+
{ id: "a", title: "Fuzzing C Compilers" },
|
|
302
|
+
{ cache },
|
|
303
|
+
);
|
|
304
|
+
const asked = fakeFetch({
|
|
305
|
+
[CR]: miss,
|
|
306
|
+
[OA]: miss,
|
|
307
|
+
[S2]: miss,
|
|
308
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
309
|
+
});
|
|
310
|
+
await verifyCitationLive(
|
|
311
|
+
{ id: "b", title: "Fuzzing C++ Compilers" },
|
|
312
|
+
{ cache },
|
|
313
|
+
);
|
|
314
|
+
expect(asked.length).toBeGreaterThan(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Guards: `paperlint build` skips its reachability probe when no citation `wouldAsk`. If it said
|
|
318
|
+
// "no" for a citation a live run asks about, a "fully cached" paper would go online anyway; if it
|
|
319
|
+
// said "yes" for one a live run answers from the cache, every warm build would probe the network.
|
|
320
|
+
test("wouldAsk: false exactly when a live run over the same cache makes no request", async () => {
|
|
321
|
+
const cites = [
|
|
322
|
+
{ id: "d", doi: "doi:10.1/X.", title: TITLE },
|
|
323
|
+
{ id: "a", arxiv: "arXiv:1706.03762", title: TITLE },
|
|
324
|
+
{ id: "t", title: TITLE, year: 2017 },
|
|
325
|
+
{ id: "v", cve: "cve-2024-0001" },
|
|
326
|
+
{ id: "g", commit: "github.com/a/b/commit/abc1234" },
|
|
327
|
+
];
|
|
328
|
+
fakeFetch({
|
|
329
|
+
[CR]: () =>
|
|
330
|
+
json(200, {
|
|
331
|
+
message: {
|
|
332
|
+
title: [TITLE],
|
|
333
|
+
issued: { "date-parts": [[2017]] },
|
|
334
|
+
items: [],
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
[OA]: () => json(200, { results: [] }),
|
|
338
|
+
[S2]: () => json(200, { data: [] }),
|
|
339
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
340
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
341
|
+
[NVD]: () => json(200, { totalResults: 1 }),
|
|
342
|
+
});
|
|
343
|
+
const cache = {};
|
|
344
|
+
const cold = [];
|
|
345
|
+
for (const c of cites) cold.push(await wouldAsk(c, cache));
|
|
346
|
+
for (const c of cites) await verifyCitationLive(c, { cache });
|
|
347
|
+
const warm = [];
|
|
348
|
+
for (const c of cites) warm.push(await wouldAsk(c, cache));
|
|
349
|
+
const again = fakeFetch({});
|
|
350
|
+
for (const c of cites) await verifyCitationLive(c, { cache });
|
|
351
|
+
assert.deepEqual(
|
|
352
|
+
{ cold, warm, again },
|
|
353
|
+
{
|
|
354
|
+
cold: [true, true, true, true, false],
|
|
355
|
+
warm: [false, false, false, false, false],
|
|
356
|
+
again: [],
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("🔴 the first match ends the lookups: nothing asked after it could change the verdict", async () => {
|
|
362
|
+
const calls = fakeFetch({
|
|
363
|
+
[CR]: () =>
|
|
364
|
+
json(200, {
|
|
365
|
+
message: { title: [TITLE], issued: { "date-parts": [[2017]] } },
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
const result = await verifyCitationLive({
|
|
369
|
+
id: "m",
|
|
370
|
+
doi: "10.1/x",
|
|
371
|
+
title: TITLE,
|
|
372
|
+
});
|
|
373
|
+
assert.deepEqual(
|
|
374
|
+
{ verdict: result.verdict, matched: result.matched_db, calls },
|
|
375
|
+
{
|
|
376
|
+
verdict: "true",
|
|
377
|
+
matched: "crossref",
|
|
378
|
+
// Not OpenAlex, not Semantic Scholar, not doi.org: a match wins over anything they say.
|
|
379
|
+
calls: ["https://api.crossref.org/works/10.1%2Fx"],
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ── #120: a service that refuses is asked once per run ─────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/** Three title-only citations nobody can find; `s2` answers Semantic Scholar's search. */
|
|
387
|
+
async function threeTitles(s2, { concurrent = false } = {}) {
|
|
388
|
+
const calls = fakeFetch({
|
|
389
|
+
[CR]: () => json(200, { message: { items: [] } }),
|
|
390
|
+
[OA]: () => json(200, { results: [] }),
|
|
391
|
+
[S2]: s2,
|
|
392
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
393
|
+
});
|
|
394
|
+
const breaker = createBreaker();
|
|
395
|
+
const cites = ["One", "Two", "Three"].map((t) => ({
|
|
396
|
+
id: t,
|
|
397
|
+
title: `${t} Paper`,
|
|
398
|
+
}));
|
|
399
|
+
const run = (c) => verifyCitationLive(c, { breaker });
|
|
400
|
+
const results = concurrent
|
|
401
|
+
? await Promise.all(cites.map(run))
|
|
402
|
+
: await (async () => {
|
|
403
|
+
const out = [];
|
|
404
|
+
for (const c of cites) out.push(await run(c));
|
|
405
|
+
return out;
|
|
406
|
+
})();
|
|
407
|
+
const count = (prefix) => calls.filter((u) => u.startsWith(prefix)).length;
|
|
408
|
+
return {
|
|
409
|
+
asked: {
|
|
410
|
+
crossref: count(CR),
|
|
411
|
+
openalex: count(OA),
|
|
412
|
+
s2: count(S2),
|
|
413
|
+
arxiv: count(AX),
|
|
414
|
+
},
|
|
415
|
+
refused: results.map((r) => r.refused),
|
|
416
|
+
verdicts: results.map((r) => r.verdict),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const REFUSED = (why) => [
|
|
421
|
+
[`semantic_scholar: ${why}`],
|
|
422
|
+
[`semantic_scholar: semantic_scholar refused earlier in this run (${why})`],
|
|
423
|
+
[`semantic_scholar: semantic_scholar refused earlier in this run (${why})`],
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
test("#120: a 429 on the first request — Semantic Scholar is asked ONCE; the other services go on", async () => {
|
|
427
|
+
assert.deepEqual(await threeTitles(() => json(429)), {
|
|
428
|
+
asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
|
|
429
|
+
refused: REFUSED("http 429"),
|
|
430
|
+
verdicts: ["unresolvable", "unresolvable", "unresolvable"],
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("#120: an HTML page where JSON was asked trips the breaker, and says so", async () => {
|
|
435
|
+
assert.deepEqual(
|
|
436
|
+
await threeTitles(() =>
|
|
437
|
+
text(200, "<!doctype html><title>slow down</title>"),
|
|
438
|
+
),
|
|
439
|
+
{
|
|
440
|
+
asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
|
|
441
|
+
refused: REFUSED("answered HTML, not JSON"),
|
|
442
|
+
verdicts: ["unresolvable", "unresolvable", "unresolvable"],
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("#120: a body that is not JSON and not HTML is a refusal too", async () => {
|
|
448
|
+
const r = await threeTitles(() => text(200, "rate limited"));
|
|
449
|
+
assert.deepEqual(r.refused[0], [
|
|
450
|
+
"semantic_scholar: answered something that is not JSON",
|
|
451
|
+
]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("#120: a timeout trips the breaker — the 15 s is paid once, not per citation", async () => {
|
|
455
|
+
vi.useFakeTimers();
|
|
456
|
+
// Never answers; the request's own timeout aborts it.
|
|
457
|
+
const pending = threeTitles(
|
|
458
|
+
(signal) =>
|
|
459
|
+
new Promise((_, reject) =>
|
|
460
|
+
signal.addEventListener("abort", () =>
|
|
461
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
let done = false;
|
|
466
|
+
void pending.finally(() => (done = true));
|
|
467
|
+
while (!done) await vi.advanceTimersByTimeAsync(1000);
|
|
468
|
+
assert.deepEqual(await pending, {
|
|
469
|
+
asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
|
|
470
|
+
refused: REFUSED("timeout"),
|
|
471
|
+
verdicts: ["unresolvable", "unresolvable", "unresolvable"],
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("#120: citations in flight at once still ask a refusing service once — the first request goes alone", async () => {
|
|
476
|
+
assert.deepEqual(
|
|
477
|
+
(await threeTitles(() => json(429), { concurrent: true })).asked,
|
|
478
|
+
{
|
|
479
|
+
crossref: 3,
|
|
480
|
+
openalex: 3,
|
|
481
|
+
s2: 1,
|
|
482
|
+
arxiv: 3,
|
|
483
|
+
},
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Guards the stated bound: requests already in flight when a service starts refusing still land
|
|
488
|
+
// (at most as many as run at once), and nothing is asked after the first refusal is recorded.
|
|
489
|
+
test("#120: a service that starts refusing costs at most the requests already in flight", async () => {
|
|
490
|
+
const breaker = createBreaker();
|
|
491
|
+
let attempts = 0;
|
|
492
|
+
let release;
|
|
493
|
+
const first = new Promise((r) => (release = r));
|
|
494
|
+
const call = (answer) =>
|
|
495
|
+
breaker.call("s2", async () => {
|
|
496
|
+
attempts++;
|
|
497
|
+
return answer();
|
|
498
|
+
});
|
|
499
|
+
const opener = call(() => first);
|
|
500
|
+
const waiters = Array.from({ length: 5 }, () =>
|
|
501
|
+
call(async () => ({ ok: false, reason: "http 429" })),
|
|
502
|
+
);
|
|
503
|
+
release({ ok: true, value: 1 });
|
|
504
|
+
await Promise.all([opener, ...waiters]);
|
|
505
|
+
const after = await call(async () => ({ ok: true, value: 2 }));
|
|
506
|
+
assert.deepEqual(
|
|
507
|
+
{ attempts, after },
|
|
508
|
+
{
|
|
509
|
+
attempts: 6,
|
|
510
|
+
after: { ok: false, reason: "s2 refused earlier in this run (http 429)" },
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("#120: a service that answered keeps being asked; one that refuses later is cut off from then on", async () => {
|
|
516
|
+
let n = 0;
|
|
517
|
+
const r = await threeTitles(() =>
|
|
518
|
+
++n === 1 ? json(200, { data: [] }) : json(503),
|
|
519
|
+
);
|
|
520
|
+
assert.deepEqual(
|
|
521
|
+
{ s2: r.asked.s2, refused: r.refused },
|
|
522
|
+
{
|
|
523
|
+
s2: 2,
|
|
524
|
+
refused: [
|
|
525
|
+
undefined,
|
|
526
|
+
["semantic_scholar: http 503"],
|
|
527
|
+
[
|
|
528
|
+
"semantic_scholar: semantic_scholar refused earlier in this run (http 503)",
|
|
529
|
+
],
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("offline: no request at all, the verdict from the citation alone", async () => {
|
|
536
|
+
const calls = fakeFetch({});
|
|
537
|
+
const result = await verifyCitationLive(
|
|
538
|
+
{ id: "o", doi: "10.1/x", title: TITLE },
|
|
539
|
+
{ offline: true },
|
|
540
|
+
);
|
|
541
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("a registry that never answers is abandoned after the timeout, as unreachable", async () => {
|
|
545
|
+
vi.useFakeTimers();
|
|
546
|
+
const calls = [];
|
|
547
|
+
vi.stubGlobal("fetch", (url, { signal }) => {
|
|
548
|
+
calls.push(url);
|
|
549
|
+
return new Promise((_, reject) =>
|
|
550
|
+
signal.addEventListener("abort", () =>
|
|
551
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
const pending = verifyCitationLive({ id: "s", title: TITLE });
|
|
556
|
+
for (let i = 0; i < 4; i++) await vi.advanceTimersByTimeAsync(15000);
|
|
557
|
+
const result = await pending;
|
|
558
|
+
assert.deepEqual(
|
|
559
|
+
{ verdict: result.verdict, calls: calls.length },
|
|
560
|
+
{ verdict: "unresolvable", calls: 4 },
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("a registry that answers 200 with no body, or a record without its title, still classifies", async () => {
|
|
565
|
+
const calls = fakeFetch({
|
|
566
|
+
[CR]: () => json(200, {}),
|
|
567
|
+
[`${OA}/doi:10.1%2Fx`]: () =>
|
|
568
|
+
json(200, { id: "W1", publication_year: 2017 }),
|
|
569
|
+
[`${OA}/doi:10.1%2Fy`]: () => json(200, {}),
|
|
570
|
+
[S2]: () => json(200, { title: "", year: 2017 }),
|
|
571
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
572
|
+
});
|
|
573
|
+
const run = async (doi) => {
|
|
574
|
+
const r = await verifyCitationLive({ id: doi, doi, title: TITLE });
|
|
575
|
+
return [r.verdict, r.matched_db];
|
|
576
|
+
};
|
|
577
|
+
// A record with no title is incomparable, which is not a mismatch, so the DOI stands confirmed:
|
|
578
|
+
// x by OpenAlex's untitled record; y, which OpenAlex does not know, by Semantic Scholar's.
|
|
579
|
+
assert.deepEqual(
|
|
580
|
+
{ x: await run("10.1/x"), y: await run("10.1/y"), calls: calls.length },
|
|
581
|
+
{
|
|
582
|
+
x: ["true", "openalex"],
|
|
583
|
+
y: ["true", "semantic_scholar"],
|
|
584
|
+
// x: Crossref, OpenAlex (match, the lookups end). y: Crossref, OpenAlex, Semantic Scholar.
|
|
585
|
+
calls: 5,
|
|
586
|
+
},
|
|
587
|
+
);
|
|
588
|
+
});
|