Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions projects/contract/one-description.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// @vitest-environment node
//
// One description per answer (spec 016, FR-003): an answer in the contract is
// not described again by the site or the pathway browser, under its own name
// or under the name a reader re-exports it as. An alias of the contract's type
// is a name, not a description, and is allowed.
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CONTRACT = path.join(ROOT, 'projects/contract');
const READERS = ['projects/website-angular/src', 'projects/pathway-browser/src'];

/** Every non-spec TypeScript module under a directory, by repository path. */
function modules(dir: string): Record<string, string> {
const found: Record<string, string> = {};
for (const file of readdirSync(path.join(ROOT, dir), { recursive: true, encoding: 'utf8' })) {
if (!file.endsWith('.ts') || file.endsWith('.spec.ts') || file.endsWith('.d.ts')) continue;
const where = path.posix.join(dir, file.split(path.sep).join('/'));
found[where] = readFileSync(path.join(ROOT, where), 'utf8');
}
return found;
}

const parse = (name: string, source: string) =>
ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true);

/** The contract's exported interfaces and types. */
function contractNames(contract: Record<string, string>): Set<string> {
const names = new Set<string>();
for (const [file, source] of Object.entries(contract))
for (const statement of parse(file, source).statements)
if (
(ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) &&
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
)
names.add(statement.name.text);
return names;
}

/** The names readers re-export the contract's types as (`export type { A as B } from '…/contract/…'`). */
function readerAliases(readers: Record<string, string>): Set<string> {
const names = new Set<string>();
for (const [file, source] of Object.entries(readers))
for (const statement of parse(file, source).statements)
if (
ts.isExportDeclaration(statement) &&
statement.moduleSpecifier &&
ts.isStringLiteral(statement.moduleSpecifier) &&
/\/contract\/[^/]+\.ts$/.test(statement.moduleSpecifier.text) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
)
for (const element of statement.exportClause.elements) names.add(element.name.text);
return names;
}

/** A type that describes an answer's shape itself, rather than naming another type. */
const describesShape = (type: ts.TypeNode): boolean =>
ts.isTypeLiteralNode(type) ||
(ts.isUnionTypeNode(type) && type.types.some(describesShape)) ||
(ts.isIntersectionTypeNode(type) && type.types.some(describesShape));

/** Every declaration in the readers that describes, again, an answer the contract has. */
function secondDescriptions(readers: Record<string, string>, names: Set<string>): string[] {
const found: string[] = [];
for (const [file, source] of Object.entries(readers)) {
const visit = (node: ts.Node) => {
if (ts.isInterfaceDeclaration(node) && names.has(node.name.text))
found.push(`${file}: interface ${node.name.text}`);
if (ts.isTypeAliasDeclaration(node) && names.has(node.name.text) && describesShape(node.type))
found.push(`${file}: type ${node.name.text}`);
ts.forEachChild(node, visit);
};
visit(parse(file, source));
}
return found;
}

const contract = Object.fromEntries(
readdirSync(CONTRACT)
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
.map((file) => [file, readFileSync(path.join(CONTRACT, file), 'utf8')])
);
const readers = Object.assign({}, ...READERS.map(modules)) as Record<string, string>;
const names = new Set([...contractNames(contract), ...readerAliases(readers)]);

describe('one description per answer', () => {
it('knows the contract by its names and the names readers give it', () => {
// The site reads the search entry's view as SearchEntry.
expect([...names]).toEqual(
expect.arrayContaining(['SearchEntryView', 'SearchEntry', 'FacetCount'])
);
});

it('finds no answer described again by the site or the pathway browser', () => {
expect(secondDescriptions(readers, names)).toEqual([]);
});

it('refuses an interface or an object type under a contract name, and allows an alias', () => {
expect(
secondDescriptions(
{
'a.ts': 'export interface SearchEntry { dbId: number }',
'b.ts': 'type FacetCount = { name: string; count: number };',
'c.ts': 'export namespace Search { export interface IconEntry { stId: string } }',
'd.ts': "import type { FacetCount as F } from '../contract/search.ts';\ntype Facet = F;",
'e.ts': 'type ResultGroup = Group<SearchEntryView>;',
},
new Set(['SearchEntry', 'FacetCount', 'IconEntry', 'Facet', 'ResultGroup'])
)
).toEqual([
'a.ts: interface SearchEntry',
'b.ts: type FacetCount',
'c.ts: interface IconEntry',
]);
});
});
7 changes: 5 additions & 2 deletions specs/016-api-contract/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ description: 'Tasks for spec 016: one API contract for the content service and t

**Goal**: the guarantee is shown, not assumed, for everything in the contract so far.

- [ ] T016 [US1] Add a spec that fails when a contract answer is also declared in `projects/website-angular/src/model` or `projects/pathway-browser` (the "no second description" check). Show it failing on a re-added local `interface SearchEntry`.
- [ ] T017 [US1] Record in this file, with the commands, a rename of one field per moved family failing the writer and every reader ([quickstart.md](quickstart.md) §1).
- [x] T016 [US1] Add a spec that fails when a contract answer is also declared in `projects/website-angular/src/model` or `projects/pathway-browser` (the "no second description" check). Show it failing on a re-added local `interface SearchEntry`. — #705. `projects/contract/one-description.spec.ts` takes the contract's exported names and the names the readers re-export them as (the site's `SearchEntry` is the contract's `SearchEntryView`), and refuses an interface or an object type under any of them in the site's or the pathway browser's code, anywhere (a namespace's too); an alias of the contract's type is a name, not a description. A re-added `interface SearchEntry` in the site's search model failed it, naming the file.
- [x] T017 [US1] Record in this file, with the commands, a rename of one field per moved family failing the writer and every reader ([quickstart.md](quickstart.md) §1). — #705. The search family, by its three moves, each a field renamed in `projects/contract/search.ts` and the two programs checked (`npx tsc -p tools/tsconfig.json --noEmit`, `npx tsc -p tsconfig.app.json --noEmit`), then undone:
- the facets (#684): `FacetCount.count` failed `tools/content-node/search-core.ts:80`, `search-endpoints.ts:96` and the site's `search/search.component.ts:339`;
- the entry (#689): `exactType` failed the writer, `search-core.ts:257`, and the site's `search.component.ts:672` (the pathway browser reads it in its templates, which the site's production build checks);
- the answers (#703): `GroupedResult.numberOfMatches` failed the writers, `search-endpoints.ts:287` and `:298`, and the site's `search.component.ts:326`, `:408`, `:415` and `:454`.

## Phase 6: User Story 4 - The description, generated (P2)

Expand Down
Loading