test: add vitest, coverage and mutation gate with availability domain core

Pure logic lives in apps/web/src/domain and must stay at 100% coverage and a
100% mutation score. The first unit is the availability logic for the hero:
parsing, resolving and formatting the next free capacity block.

Vitest is pinned to 4.1.11 because @stryker-mutator/vitest-runner 10.0.0
runs zero tests per mutant under vitest 5 and reports surviving mutants.
ESLint now ignores Stryker, coverage and report output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:12:47 +02:00
co-authored by Claude Sonnet 5.5
parent 43d0a49c3d
commit 0995a3b5ca
7 changed files with 14550 additions and 6122 deletions
+9 -2
View File
@@ -9,7 +9,8 @@
"build": "next build --webpack",
"start": "next start",
"lint": "eslint app src scripts video",
"test": "echo \"No tests configured\"",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:links": "tsx ./scripts/test-links.ts",
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
"generate-estimate": "tsx ./scripts/generate-estimate.ts",
@@ -21,6 +22,7 @@
"video:render:all": "npm run video:render:contact && npm run video:render:button",
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
"typecheck": "tsc --noEmit",
"test:mutation": "stryker run",
"check:og": "tsx scripts/check-og-images.ts",
"check:forms": "tsx scripts/check-forms.ts"
},
@@ -104,12 +106,16 @@
"@mintel/next-utils": "^1.9.0",
"@mintel/tsconfig": "^1.9.0",
"@next/eslint-plugin-next": "^16.1.6",
"@stryker-mutator/core": "^10.0.0",
"@stryker-mutator/typescript-checker": "^10.0.0",
"@stryker-mutator/vitest-runner": "^10.0.0",
"@tailwindcss/typography": "^0.5.15",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.6",
"@types/nodemailer": "^7.0.10",
"@types/prismjs": "^1.26.5",
"@types/qrcode": "^1.5.6",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.20",
"cheerio": "^1.1.2",
"concurrently": "^9.2.1",
@@ -121,7 +127,8 @@
"require-extensions": "^0.0.4",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "^8.54.0"
"typescript-eslint": "^8.54.0",
"vitest": "^4.1.11"
},
"repository": {
"type": "git",
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from "vitest";
import {
formatAvailability,
parseAvailabilityConfig,
resolveAvailability,
} from "./availability";
describe("parseAvailabilityConfig", () => {
it("accepts an ISO date and a positive hour count", () => {
expect(
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek: 20 }),
).toEqual({
ok: true,
value: { freeFrom: "2026-11-01", hoursPerWeek: 20 },
});
});
it("accepts zero hours to express fully booked", () => {
expect(
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek: 0 }),
).toEqual({ ok: true, value: { freeFrom: "2026-11-01", hoursPerWeek: 0 } });
});
it.each([
["not a date", "soon"],
["a non-ISO format", "01.11.2026"],
["an impossible calendar date", "2026-02-30"],
["a missing value", undefined],
])("rejects %s as invalid-date", (_label, freeFrom) => {
expect(parseAvailabilityConfig({ freeFrom, hoursPerWeek: 20 })).toEqual({
ok: false,
error: "invalid-date",
});
});
it.each([
["text before the date", "x2026-11-01"],
["text after the date", "2026-11-01x"],
["month zero", "2026-00-10"],
["month thirteen", "2026-13-10"],
["day zero", "2026-01-00"],
["day 32", "2026-01-32"],
["29 February in a common year", "2026-02-29"],
["29 February in a century year", "1900-02-29"],
["31 April", "2026-04-31"],
])("rejects %s as invalid-date", (_label, freeFrom) => {
expect(parseAvailabilityConfig({ freeFrom, hoursPerWeek: 20 })).toEqual({
ok: false,
error: "invalid-date",
});
});
it.each([
["29 February in a leap year", "2028-02-29"],
["29 February in a 400-year leap year", "2000-02-29"],
["31 January in a leap year", "2028-01-31"],
])("accepts %s", (_label, freeFrom) => {
expect(parseAvailabilityConfig({ freeFrom, hoursPerWeek: 20 }).ok).toBe(
true,
);
});
it("accepts the last day and rejects the day after it, for every month", () => {
const lastDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
lastDays.forEach((last, index) => {
const month = String(index + 1).padStart(2, "0");
const accept = parseAvailabilityConfig({
freeFrom: `2026-${month}-${String(last).padStart(2, "0")}`,
hoursPerWeek: 20,
});
const reject = parseAvailabilityConfig({
freeFrom: `2026-${month}-${String(last + 1).padStart(2, "0")}`,
hoursPerWeek: 20,
});
expect(accept.ok).toBe(true);
expect(reject).toEqual({ ok: false, error: "invalid-date" });
});
});
it("accepts exactly the number of hours a week has", () => {
expect(
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek: 168 }).ok,
).toBe(true);
});
it.each([
["negative hours", -1],
["fractional hours", 12.5],
["more hours than a week has", 169],
["a string", "20"],
["a missing value", undefined],
])("rejects %s as invalid-hours", (_label, hoursPerWeek) => {
expect(
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek }),
).toEqual({ ok: false, error: "invalid-hours" });
});
it("rejects non-object input as invalid-date", () => {
expect(parseAvailabilityConfig(null)).toEqual({
ok: false,
error: "invalid-date",
});
expect(parseAvailabilityConfig("2026-11-01")).toEqual({
ok: false,
error: "invalid-date",
});
});
});
describe("resolveAvailability", () => {
const config = { freeFrom: "2026-11-01", hoursPerWeek: 20 } as const;
it("is available now when the free date is in the past", () => {
expect(resolveAvailability(config, "2026-12-15")).toEqual({
kind: "available-now",
hoursPerWeek: 20,
});
});
it("is available now on the free date itself", () => {
expect(resolveAvailability(config, "2026-11-01")).toEqual({
kind: "available-now",
hoursPerWeek: 20,
});
});
it("is available from the free date while it is still in the future", () => {
expect(resolveAvailability(config, "2026-10-31")).toEqual({
kind: "available-from",
date: "2026-11-01",
hoursPerWeek: 20,
});
});
it("is booked when zero hours are free, whatever the date", () => {
expect(
resolveAvailability(
{ freeFrom: "2026-01-01", hoursPerWeek: 0 },
"2026-12-15",
),
).toEqual({ kind: "booked" });
});
});
describe("formatAvailability", () => {
it("states hours per week when available now", () => {
expect(
formatAvailability({ kind: "available-now", hoursPerWeek: 20 }),
).toBe("Available now · 20 h/week");
});
it("names the next free date in plain words", () => {
expect(
formatAvailability({
kind: "available-from",
date: "2026-11-01",
hoursPerWeek: 20,
}),
).toBe("Next free block: 1 Nov 2026 · 20 h/week");
});
it("covers every month name", () => {
const names = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
names.forEach((name, index) => {
const month = String(index + 1).padStart(2, "0");
expect(
formatAvailability({
kind: "available-from",
date: `2027-${month}-09`,
hoursPerWeek: 15,
}),
).toBe(`Next free block: 9 ${name} 2027 · 15 h/week`);
});
});
it("says so plainly when fully booked", () => {
expect(formatAvailability({ kind: "booked" })).toBe(
"Currently booked · next block on request",
);
});
});
+111
View File
@@ -0,0 +1,111 @@
export type AvailabilityConfig = {
readonly freeFrom: string;
readonly hoursPerWeek: number;
};
export type Availability =
| { readonly kind: "available-now"; readonly hoursPerWeek: number }
| {
readonly kind: "available-from";
readonly date: string;
readonly hoursPerWeek: number;
}
| { readonly kind: "booked" };
export type ParseError = "invalid-date" | "invalid-hours";
export type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const HOURS_PER_WEEK = 7 * 24;
const MONTH_NAMES = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;
const THIRTY_DAY_MONTHS: ReadonlySet<number> = new Set([4, 6, 9, 11]);
const isLeapYear = (year: number): boolean =>
year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
const daysInMonth = (year: number, month: number): number => {
if (month === 2) return isLeapYear(year) ? 29 : 28;
return THIRTY_DAY_MONTHS.has(month) ? 30 : 31;
};
const isRealIsoDate = (value: unknown): value is string => {
if (typeof value !== "string") return false;
const match = ISO_DATE.exec(value);
if (!match) return false;
const [year, month, day] = match.slice(1).map(Number) as [
number,
number,
number,
];
return (
month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)
);
};
const isWeeklyHours = (value: unknown): value is number =>
typeof value === "number" &&
Number.isInteger(value) &&
value >= 0 &&
value <= HOURS_PER_WEEK;
export function parseAvailabilityConfig(
raw: unknown,
): Result<AvailabilityConfig, ParseError> {
const { freeFrom, hoursPerWeek } = (raw ?? {}) as Record<string, unknown>;
if (!isRealIsoDate(freeFrom)) return { ok: false, error: "invalid-date" };
if (!isWeeklyHours(hoursPerWeek))
return { ok: false, error: "invalid-hours" };
return { ok: true, value: { freeFrom, hoursPerWeek } };
}
export function resolveAvailability(
config: AvailabilityConfig,
today: string,
): Availability {
if (config.hoursPerWeek === 0) return { kind: "booked" };
if (config.freeFrom <= today) {
return { kind: "available-now", hoursPerWeek: config.hoursPerWeek };
}
return {
kind: "available-from",
date: config.freeFrom,
hoursPerWeek: config.hoursPerWeek,
};
}
const formatDate = (isoDate: string): string => {
const [year, month, day] = isoDate.split("-").map(Number) as [
number,
number,
number,
];
return `${day} ${MONTH_NAMES[month - 1]} ${year}`;
};
export function formatAvailability(availability: Availability): string {
switch (availability.kind) {
case "available-now":
return `Available now · ${availability.hoursPerWeek} h/week`;
case "available-from":
return `Next free block: ${formatDate(availability.date)} · ${availability.hoursPerWeek} h/week`;
case "booked":
return "Currently booked · next block on request";
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"checkers": ["typescript"],
"tsconfigFile": "tsconfig.json",
"mutate": ["src/domain/**/*.ts", "!src/domain/**/*.spec.ts"],
"reporters": ["clear-text", "json"],
"jsonReporter": {
"fileName": "reports/mutation/mutation.json"
},
"tempDirName": ".stryker-tmp",
"cleanTempDir": true,
"thresholds": {
"high": 100,
"low": 100,
"break": 100
},
"plugins": [
"@stryker-mutator/vitest-runner",
"@stryker-mutator/typescript-checker"
],
"ignorePatterns": [
"cloned-websites",
"public",
"out",
"dist",
"storage",
"data",
"content",
"video",
"reports",
".next",
".turbo",
"coverage",
"backups",
"logs",
"docker"
],
"coverageAnalysis": "off",
"vitest": {
"related": false
}
}
+27
View File
@@ -0,0 +1,27 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const root = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
resolve: { alias: { "@": root } },
test: {
environment: "node",
include: ["src/**/*.spec.ts", "tests/unit/**/*.spec.ts"],
passWithNoTests: false,
coverage: {
provider: "v8",
// The functional core: pure logic lives in src/domain and must be fully covered.
include: ["src/domain/**/*.ts"],
exclude: ["src/domain/**/*.spec.ts"],
reporter: ["text", "json-summary", "html"],
reportsDirectory: "reports/coverage",
thresholds: {
lines: 100,
functions: 100,
branches: 100,
statements: 100,
},
},
},
});
+3
View File
@@ -24,6 +24,9 @@ export default tseslint.config(
"**/out/**",
"**/cloned-websites/**",
"**/public/showcase/**",
"**/.stryker-tmp/**",
"**/reports/**",
"**/coverage/**",
],
},
js.configs.recommended,
+14163 -6120
View File
File diff suppressed because it is too large Load Diff