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:
@@ -9,7 +9,8 @@
|
|||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint app src scripts video",
|
"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:links": "tsx ./scripts/test-links.ts",
|
||||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
||||||
"generate-estimate": "tsx ./scripts/generate-estimate.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",
|
"video:render:all": "npm run video:render:contact && npm run video:render:button",
|
||||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:mutation": "stryker run",
|
||||||
"check:og": "tsx scripts/check-og-images.ts",
|
"check:og": "tsx scripts/check-og-images.ts",
|
||||||
"check:forms": "tsx scripts/check-forms.ts"
|
"check:forms": "tsx scripts/check-forms.ts"
|
||||||
},
|
},
|
||||||
@@ -104,12 +106,16 @@
|
|||||||
"@mintel/next-utils": "^1.9.0",
|
"@mintel/next-utils": "^1.9.0",
|
||||||
"@mintel/tsconfig": "^1.9.0",
|
"@mintel/tsconfig": "^1.9.0",
|
||||||
"@next/eslint-plugin-next": "^16.1.6",
|
"@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",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/node": "^25.0.6",
|
"@types/node": "^25.0.6",
|
||||||
"@types/nodemailer": "^7.0.10",
|
"@types/nodemailer": "^7.0.10",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
@@ -121,7 +127,8 @@
|
|||||||
"require-extensions": "^0.0.4",
|
"require-extensions": "^0.0.4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "^8.54.0"
|
"typescript-eslint": "^8.54.0",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -24,6 +24,9 @@ export default tseslint.config(
|
|||||||
"**/out/**",
|
"**/out/**",
|
||||||
"**/cloned-websites/**",
|
"**/cloned-websites/**",
|
||||||
"**/public/showcase/**",
|
"**/public/showcase/**",
|
||||||
|
"**/.stryker-tmp/**",
|
||||||
|
"**/reports/**",
|
||||||
|
"**/coverage/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
|||||||
Generated
+14163
-6120
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user