Merge branch 'phase/57-client-typecheck'

# Conflicts:
#	docs/phases/10-craft/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 13:12:16 +03:00
8 changed files with 137 additions and 25 deletions
+7 -4
View File
@@ -46,8 +46,9 @@ dotnet test tests/HSchool.People.Tests --filter FullyQualifiedName~YearlyIntake
npm --prefix src/HSchool.Client test -- src/ui/peoplePanel.test.ts npm --prefix src/HSchool.Client test -- src/ui/peoplePanel.test.ts
``` ```
Solution-wide `dotnet test` and a full client `npm test` are CI, or when the user asked. Agents Client `npm test` runs `tsc -b` (`typecheck`) then vitest; extra args after `--` go to vitest.
scope the run — Testing policy. `test:watch` is vitest only. Solution-wide `dotnet test` and a full client `npm test` are CI, or
when the user asked. Agents scope the run — Testing policy.
```bash ```bash
npm --prefix src/HSchool.Client run build npm --prefix src/HSchool.Client run build
@@ -126,7 +127,7 @@ say so explicitly in the change description.
## Testing policy ## Testing policy
**What to run.** One project from the list below, filtered to the new or changed class while **What to run.** One project from the list below, filtered to the new or changed class while
iterating. Client Vitest only if `src/HSchool.Client` changed. `HSchool.AppHost.Tests` only if iterating. Client `npm test` (typecheck + Vitest) only if `src/HSchool.Client` changed. `HSchool.AppHost.Tests` only if
the change is HTTP, WebSocket, or host wiring — it boots a server. Do not `dotnet test` the the change is HTTP, WebSocket, or host wiring — it boots a server. Do not `dotnet test` the
solution, do not run client and .NET together "to be sure", do not re-run after a merge that solution, do not run client and .NET together "to be sure", do not re-run after a merge that
only resolved a status line. Solution-wide is CI. only resolved a status line. Solution-wide is CI.
@@ -157,7 +158,9 @@ only resolved a status line. Solution-wide is CI.
- Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the - Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the
pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and
the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries
and date formatting stay in `i18n/strings.test.ts` and `format/gameTime.test.ts`. and date formatting stay in `i18n/strings.test.ts` and `format/gameTime.test.ts`. A duplicate
key in `strings.ts` is a typecheck failure (`TS1117` / `TS2300`); client `npm test` runs tsc
before vitest, so it is red without `npm run build`.
## Dependencies ## Dependencies
+8 -8
View File
@@ -11,20 +11,20 @@
## Задачи ## Задачи
- [ ] `package.json``"test":` сначала `tsc -b` (`typecheck`), потом `vitest run`. Без - [x] `package.json``"test":` сначала `tsc -b` (`typecheck`), потом `vitest run`. Без
`&&` скрывать код выхода typecheck `&&` скрывать код выхода typecheck
- [ ] Убрать петлю `"hschool-client": "file:"` из `dependencies`; `npm test` и `npm run build` - [x] Убрать петлю `"hschool-client": "file:"` из `dependencies`; `npm test` и `npm run build`
без неё без неё
- [ ] `AGENTS.md` Commands и Testing policy: клиентский `npm test` включает typecheck - [x] `AGENTS.md` Commands и Testing policy: клиентский `npm test` включает typecheck
- [ ] `test:watch` по-прежнему только vitest — typecheck на каждый файл в watch не нужен - [x] `test:watch` по-прежнему только vitest — typecheck на каждый файл в watch не нужен
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] `npm --prefix src/HSchool.Client test` гоняет tsc и vitest и проходит на текущем дереве - [x] `npm --prefix src/HSchool.Client test` гоняет tsc и vitest и проходит на текущем дереве
- [ ] Дубликат ключа в одном объекте `strings.ts` (ru или en) делает `npm test` красным без - [x] Дубликат ключа в одном объекте `strings.ts` (ru или en) делает `npm test` красным без
`npm run build` `npm run build`
- [ ] В `package.json` нет зависимости `hschool-client` - [x] В `package.json` нет зависимости `hschool-client`
- [ ] `npm --prefix src/HSchool.Client run build` проходит - [x] `npm --prefix src/HSchool.Client run build` проходит
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -16,7 +16,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [56. Слои одной пачкой](56-architecture-tests.md) | ✅ | Один проект вместо четырёх копипаст | | [56. Слои одной пачкой](56-architecture-tests.md) | ✅ | Один проект вместо четырёх копипаст |
| [57. tsc в npm test](57-client-typecheck.md) | 🔄 | Дубликат ключа красный без `npm run build` | | [57. tsc в npm test](57-client-typecheck.md) | | Дубликат ключа красный без `npm run build` |
**Этап B — горячие точки.** Поведение то же. **Этап B — горячие точки.** Поведение то же.
-7
View File
@@ -7,9 +7,6 @@
"": { "": {
"name": "hschool-client", "name": "hschool-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"hschool-client": "file:"
},
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"happy-dom": "^20.11.2", "happy-dom": "^20.11.2",
@@ -597,10 +594,6 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/hschool-client": {
"resolved": "",
"link": true
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+1 -4
View File
@@ -11,7 +11,7 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"test": "vitest run", "test": "node ./scripts/run-test.mjs",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
@@ -20,8 +20,5 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.2.1", "vite": "^8.2.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"dependencies": {
"hschool-client": "file:"
} }
} }
@@ -0,0 +1,81 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const clientRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const tscBin = require.resolve('typescript/bin/tsc');
const pkg = JSON.parse(readFileSync(path.join(clientRoot, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
dependencies?: Record<string, string>;
};
describe('npm test script', () => {
it('runs typecheck then vitest, without swallowing tsc', () => {
expect(pkg.scripts.test).toBe('node ./scripts/run-test.mjs');
const runner = readFileSync(path.join(clientRoot, 'scripts', 'run-test.mjs'), 'utf8');
expect(runner).toContain("'-b'");
expect(runner).toContain("'run'");
expect(runner).toMatch(/if \(typecheck !== 0\)/);
expect(pkg.scripts.typecheck).toBe('tsc -b');
});
it('keeps watch as vitest only', () => {
expect(pkg.scripts['test:watch']).toBe('vitest');
expect(pkg.scripts['test:watch']).not.toMatch(/typecheck|tsc/);
});
it('does not depend on the hschool-client file: loop', () => {
expect(pkg.dependencies?.['hschool-client']).toBeUndefined();
});
});
describe('duplicate dictionary keys', () => {
it('makes tsc fail on a strings.ts ru object without vite build', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'hschool-dup-'));
try {
const original = readFileSync(path.join(clientRoot, 'src', 'i18n', 'strings.ts'), 'utf8');
const needle = " createSchool: 'Создать школу',";
expect(original).toContain(needle);
const duplicate = original.replace(
needle,
`${needle}\n createSchool: 'дубликат',`,
);
writeFileSync(path.join(dir, 'strings.ts'), duplicate);
writeFileSync(
path.join(dir, 'locale.ts'),
readFileSync(path.join(clientRoot, 'src', 'i18n', 'locale.ts'), 'utf8'),
);
writeFileSync(
path.join(dir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
allowImportingTsExtensions: true,
verbatimModuleSyntax: true,
lib: ['ES2022', 'DOM'],
},
include: ['strings.ts', 'locale.ts'],
}),
);
const result = spawnSync(process.execPath, [tscBin, '-p', dir], {
encoding: 'utf8',
});
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
expect(result.status).not.toBe(0);
expect(output).toMatch(/TS1117|TS2300|TS1118/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+38
View File
@@ -0,0 +1,38 @@
// `tsc -b && vitest run` on Windows npm (tsc.cmd) can drop the typecheck exit code.
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const clientRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const win = process.platform === 'win32';
function statusOf(result) {
if (result.error) {
console.error(result.error);
return 1;
}
return result.status ?? 1;
}
const typecheck = statusOf(
spawnSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-b'], {
cwd: clientRoot,
stdio: 'inherit',
}),
);
if (typecheck !== 0) {
process.exit(typecheck);
}
const vitest = path.join(clientRoot, 'node_modules', '.bin', win ? 'vitest.cmd' : 'vitest');
process.exit(
statusOf(
spawnSync(vitest, ['run', ...process.argv.slice(2)], {
cwd: clientRoot,
stdio: 'inherit',
shell: win,
}),
),
);
+1 -1
View File
@@ -20,5 +20,5 @@
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "scripts/**/*.ts"]
} }