feat(report): birth-sky report cover bound to the report's birth fingerprint (F1–F3)
Product decision A (TASK-birth-sky-followup-20260928): - every generation path writes subject.birthFingerprint (stored-row fingerprint, same function as the chart page's profileFingerprint); never into the evidence bundle the model reads - GET /api/reports/:id compares on the server and returns only report.coverSubject (subject id or null); legacy reports never get one - report page shows the night cover above the body, paper cover alone on the first printed A4 page; silent on any failure, no waiting state - TS / JSON schema / Python contract accept the optional field - BLOCKED T5 struck through; DESIGN §17, CHANGELOG, checklist, PROGRESS Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
14ef18ac24
commit
a9a917d50c
@@ -1447,3 +1447,98 @@ test("failed report reads aggregate existing section error codes without importi
|
||||
assert.match(createRoute, /row\.section_id === LONGFORM_SNAPSHOT_SECTION_ID\) continue/);
|
||||
assert.doesNotMatch(itemRoute, /personal-report-generation/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report cover binding (TASK-birth-sky-followup-20260928 F1/F2, decision A)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BIRTH_FP = "0123456789abcdef0123456789abcdef";
|
||||
const OTHER_FP = "fedcba9876543210fedcba9876543210";
|
||||
|
||||
test("core create: inline generation writes subject.birthFingerprint from the stamped profile", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const stamped = await resolveReportCreate(baseDeps({
|
||||
persistence,
|
||||
profile: { ...profileFixture(), report_birth_fingerprint: BIRTH_FP },
|
||||
}));
|
||||
assert.equal(stamped.status, 201);
|
||||
assert.equal(persistence.rows.get(REPORT_ID)?.reportDocument?.subject.birthFingerprint, BIRTH_FP);
|
||||
// The browser-facing projection carries no subject at all.
|
||||
assert.doesNotMatch(JSON.stringify(stamped.body), new RegExp(BIRTH_FP));
|
||||
|
||||
const unstamped = new MemoryPersistence();
|
||||
await resolveReportCreate(baseDeps({ persistence: unstamped }));
|
||||
assert.equal("birthFingerprint" in (unstamped.rows.get(REPORT_ID)?.reportDocument?.subject ?? {}), false);
|
||||
});
|
||||
|
||||
async function readWithCover(input: Readonly<{
|
||||
storedFingerprint: string | null;
|
||||
chartProfileId?: string | null;
|
||||
current: (userId: string, subjectId: string) => Promise<string | null>;
|
||||
}>) {
|
||||
const persistence = new MemoryPersistence();
|
||||
const subject = {
|
||||
displayName: "测试用户",
|
||||
birthTimeStatus: "confirmed",
|
||||
birthPlaceLabel: "北京",
|
||||
...(input.storedFingerprint ? { birthFingerprint: input.storedFingerprint } : {}),
|
||||
};
|
||||
persistence.rows.set(REPORT_ID, {
|
||||
...seedRecord(),
|
||||
chartProfileId: input.chartProfileId ?? null,
|
||||
reportDocument: { schemaVersion: "report_document.v2", subject } as unknown as PersonalReportRecord["reportDocument"],
|
||||
});
|
||||
const asked: string[] = [];
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: "https://jyotisha.chat/api/reports/x",
|
||||
origin: null,
|
||||
allowedOrigins: [],
|
||||
userId: UUID_A,
|
||||
reportId: REPORT_ID,
|
||||
persistence,
|
||||
validateReadyDocument: acceptAnyDocument,
|
||||
loadLongformAppendix: async () => ({ status: "ready", lastErrorCode: null, markdown: "# 报告\n\n正文。" }),
|
||||
loadCurrentBirthFingerprint: async (userId, subjectId) => {
|
||||
asked.push(`${userId}:${subjectId}`);
|
||||
return input.current(userId, subjectId);
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return { response, asked, report: response.body.report as Record<string, unknown> };
|
||||
}
|
||||
|
||||
test("core read: coverSubject is the subject id when the stored fingerprint matches the current one", async () => {
|
||||
const self = await readWithCover({ storedFingerprint: BIRTH_FP, current: async () => BIRTH_FP });
|
||||
assert.equal(self.report.coverSubject, "self");
|
||||
assert.deepEqual(self.asked, [`${UUID_A}:self`]);
|
||||
const person = await readWithCover({ storedFingerprint: BIRTH_FP, chartProfileId: SESSION_ID, current: async () => BIRTH_FP });
|
||||
assert.equal(person.report.coverSubject, SESSION_ID);
|
||||
assert.deepEqual(person.asked, [`${UUID_A}:${SESSION_ID}`]);
|
||||
// Serialized response: no fingerprint, no birth fields.
|
||||
const body = JSON.stringify(self.response.body);
|
||||
assert.doesNotMatch(body, new RegExp(BIRTH_FP));
|
||||
assert.doesNotMatch(body, /birthFingerprint|birth_date|latitude|longitude|timezone|birthPlaceLabel|北京/);
|
||||
});
|
||||
|
||||
test("core read: coverSubject is null when the birth data changed since the report", async () => {
|
||||
const { report, response } = await readWithCover({ storedFingerprint: BIRTH_FP, current: async () => OTHER_FP });
|
||||
assert.equal(report.coverSubject, null);
|
||||
assert.doesNotMatch(JSON.stringify(response.body), new RegExp(`${BIRTH_FP}|${OTHER_FP}`));
|
||||
});
|
||||
|
||||
test("core read: coverSubject is null for a legacy report without the field, and nothing is looked up", async () => {
|
||||
const { report, asked } = await readWithCover({ storedFingerprint: null, current: async () => BIRTH_FP });
|
||||
assert.equal(report.coverSubject, null);
|
||||
assert.deepEqual(asked, []);
|
||||
});
|
||||
|
||||
test("core read: coverSubject is null for a subject that is not the reader's (foreign, deleted, unreadable)", async () => {
|
||||
const foreign = await readWithCover({
|
||||
storedFingerprint: BIRTH_FP,
|
||||
chartProfileId: SESSION_ID,
|
||||
current: async () => { throw new Error("subject_not_found"); },
|
||||
});
|
||||
assert.equal(foreign.report.coverSubject, null);
|
||||
const missing = await readWithCover({ storedFingerprint: BIRTH_FP, current: async () => null });
|
||||
assert.equal(missing.report.coverSubject, null);
|
||||
});
|
||||
|
||||
@@ -1031,3 +1031,45 @@ test("sectioned generation never uses English character-halving for output token
|
||||
assert.match(generationSource, /sectionOutputTokenBudget\(entry\.targetCharacters\.max\)/);
|
||||
assert.doesNotMatch(generationSource, /targetCharacters\.max\s*\/\s*2/);
|
||||
});
|
||||
|
||||
// Report cover binding (TASK-birth-sky-followup-20260928 F1): both assemblies
|
||||
// write subject.birthFingerprint; the model-facing bundle never carries it.
|
||||
test("single-call and sectioned generation write subject.birthFingerprint only when given", async () => {
|
||||
const fingerprint = "0123456789abcdef0123456789abcdef";
|
||||
const bundle = makeBundle({ themes: fullThemes.slice(0, 3), charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)] });
|
||||
const seen: string[] = [];
|
||||
const agent = sectionedAgent({});
|
||||
const watching: ReportAgentPort = {
|
||||
...agent,
|
||||
async generateSection(sectionBundle, section, titles, options) {
|
||||
seen.push(JSON.stringify(sectionBundle));
|
||||
return agent.generateSection!(sectionBundle, section, titles, options);
|
||||
},
|
||||
};
|
||||
const sectioned = readyV2(await generatePersonalReport({
|
||||
reportId: REPORT_ID,
|
||||
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
requestId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
bundle,
|
||||
depth: "research",
|
||||
agent: watching,
|
||||
sectionService: inMemorySectionService(),
|
||||
now: () => new Date(GENERATED_AT),
|
||||
birthFingerprint: fingerprint,
|
||||
}));
|
||||
assert.equal(sectioned.subject.birthFingerprint, fingerprint);
|
||||
assert.ok(seen.length > 0);
|
||||
for (const payload of seen) assert.doesNotMatch(payload, new RegExp(fingerprint));
|
||||
|
||||
const single = readyV2(await generatePersonalReport({
|
||||
reportId: REPORT_ID,
|
||||
bundle,
|
||||
depth: "research",
|
||||
agent: fakeWriter(writerOutputFor),
|
||||
now: () => new Date(GENERATED_AT),
|
||||
birthFingerprint: fingerprint,
|
||||
}));
|
||||
assert.equal(single.subject.birthFingerprint, fingerprint);
|
||||
const legacy = readyV2(await run(bundle, fakeWriter(writerOutputFor)));
|
||||
assert.equal("birthFingerprint" in legacy.subject, false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveServerOwnedChartBirth } from "../src/lib/chart-birth-truth.ts";
|
||||
import { prepareChartViewProfile } from "../src/lib/chart-view-profile-read.ts";
|
||||
import { safeParseReportDocument } from "../src/lib/personal-report-contract.ts";
|
||||
import { buildLongformCoverDocument } from "../src/lib/personal-report-longform-cover.ts";
|
||||
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
|
||||
import {
|
||||
REPORT_BIRTH_FINGERPRINT_KEY,
|
||||
readReportBirthFingerprint,
|
||||
reportCoverSubject,
|
||||
reportDocumentBirthFingerprint,
|
||||
stampReportBirthFingerprint,
|
||||
subjectBirthFingerprint,
|
||||
withReportBirthFingerprint,
|
||||
} from "../src/lib/report-birth-binding.ts";
|
||||
|
||||
// Report cover binding, product decision A (TASK-birth-sky-followup-20260928 F1/F2).
|
||||
// Fictional sample birth only: 1994-05-18 07:40 UTC+8, 30.27N 120.15E.
|
||||
|
||||
const storedSelf = {
|
||||
name: "虚构甲",
|
||||
birth_date: "1994-05-18",
|
||||
reported_birth_time: "07:40:00",
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_status: "reported",
|
||||
birth_place_label: "虚构城",
|
||||
latitude: 30.27,
|
||||
longitude: 120.15,
|
||||
timezone_offset: 8,
|
||||
timezone_id: "Asia/Shanghai",
|
||||
ayanamsa: "lahiri",
|
||||
};
|
||||
|
||||
const routeSource = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
const itemRouteSource = readFileSync(new URL("../src/app/api/reports/[reportId]/route.ts", import.meta.url), "utf8");
|
||||
const workerSource = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
||||
const longformSource = readFileSync(new URL("../src/lib/personal-report-longform-generate.ts", import.meta.url), "utf8");
|
||||
const routeCoreSource = readFileSync(new URL("../src/lib/personal-report-route-core.ts", import.meta.url), "utf8");
|
||||
const generationSource = readFileSync(new URL("../src/lib/personal-report-generation.ts", import.meta.url), "utf8");
|
||||
|
||||
function fakeSupabase(rows: Readonly<{ profiles?: Record<string, unknown> | null }>) {
|
||||
return {
|
||||
from(table: string) {
|
||||
const query = {
|
||||
select: () => query,
|
||||
eq: () => query,
|
||||
maybeSingle: async () => ({
|
||||
data: table === "profiles" ? (rows.profiles ?? null) : { rectification_case_id: null },
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the report fingerprint is the chart page's profileFingerprint (same function, stored row)", async () => {
|
||||
const withoutOffset = { ...storedSelf, timezone_offset: null };
|
||||
const filled = { ...withoutOffset, timezone_offset: 8 };
|
||||
const chartView = await prepareChartViewProfile({ row: withoutOffset, resolveTimezone: async () => filled });
|
||||
assert.equal(chartView.ok, true);
|
||||
assert.equal(subjectBirthFingerprint(withoutOffset), chartView.birth.fingerprint);
|
||||
// The in-memory timezone fill changes the fingerprint, so it must be taken before the fill.
|
||||
assert.notEqual(resolveServerOwnedChartBirth(filled).fingerprint, chartView.birth.fingerprint);
|
||||
const stamped = stampReportBirthFingerprint(withoutOffset, filled);
|
||||
assert.equal(readReportBirthFingerprint(stamped), chartView.birth.fingerprint);
|
||||
assert.match(String((stamped as Record<string, unknown>)[REPORT_BIRTH_FINGERPRINT_KEY]), /^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
test("worker profile loader stamps the stored-row fingerprint before the timezone fill", async () => {
|
||||
const profile = await loadReportWorkerProfile(fakeSupabase({ profiles: storedSelf }), "user-1", "self");
|
||||
assert.equal(readReportBirthFingerprint(profile), subjectBirthFingerprint(storedSelf));
|
||||
assert.equal((profile as Record<string, unknown>).latitude, 30.27, "the generators still read the same row");
|
||||
});
|
||||
|
||||
test("every generation path writes subject.birthFingerprint from the stamped profile", () => {
|
||||
// POST /api/reports (inline and queued create) stamps before the timezone fill.
|
||||
assert.match(routeSource, /profile = stampReportBirthFingerprint\(\s*resolved\.row,\s*await resolveMissingBirthTimezoneOffset\(resolved\.row/);
|
||||
// Inline route-core generation, the queued writer and the queued longform all pass it on.
|
||||
assert.match(routeCoreSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/);
|
||||
assert.match(workerSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/);
|
||||
assert.match(longformSource, /birthFingerprint: readReportBirthFingerprint\(profile\),/);
|
||||
// Both document assemblies (single-call and sectioned) stamp before the guard and the final parse.
|
||||
assert.equal(generationSource.match(/applyReportGuard\(withReportBirthFingerprint\(candidate, deps\.birthFingerprint\)/g)?.length, 2);
|
||||
// Never into the evidence bundle the model reads.
|
||||
assert.doesNotMatch(generationSource, /bundle[^\n]*birthFingerprint/);
|
||||
});
|
||||
|
||||
test("longform cover document carries the fingerprint and still parses; without it nothing is added", () => {
|
||||
const input = {
|
||||
report: {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
reportType: "personal_full" as const,
|
||||
presentationMode: "default" as const,
|
||||
depth: "standard" as const,
|
||||
requestedThemes: ["career"],
|
||||
skillName: "jyotish-vedic-astrology",
|
||||
skillVersion: "6.9.14",
|
||||
skillSourceCommit: null,
|
||||
skillSnapshotSha256: "ab".repeat(32),
|
||||
},
|
||||
subject: { displayName: "虚构甲", birthTimeStatus: "reported" as const, birthPlaceLabel: "虚构城" },
|
||||
markdown: "# 个人长报告\n\n## 摘要\n\n事业方向保持观察。\n",
|
||||
generatedAt: "2026-09-28T00:00:00.000Z",
|
||||
};
|
||||
const fingerprint = subjectBirthFingerprint(storedSelf);
|
||||
const bound = buildLongformCoverDocument({ ...input, birthFingerprint: fingerprint });
|
||||
assert.equal(bound.subject.birthFingerprint, fingerprint);
|
||||
assert.equal(reportDocumentBirthFingerprint(bound), fingerprint);
|
||||
const legacy = buildLongformCoverDocument(input);
|
||||
assert.equal("birthFingerprint" in legacy.subject, false);
|
||||
assert.equal(reportDocumentBirthFingerprint(legacy), null);
|
||||
});
|
||||
|
||||
test("documents without the field (old reports) still parse; a malformed fingerprint does not", () => {
|
||||
const document = buildLongformCoverDocument({
|
||||
report: {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
reportType: "personal_full",
|
||||
presentationMode: "default",
|
||||
depth: "standard",
|
||||
requestedThemes: ["career"],
|
||||
skillName: "jyotish-vedic-astrology",
|
||||
skillVersion: "6.9.14",
|
||||
skillSourceCommit: null,
|
||||
skillSnapshotSha256: "ab".repeat(32),
|
||||
},
|
||||
subject: { displayName: "虚构甲", birthTimeStatus: "reported", birthPlaceLabel: "虚构城" },
|
||||
markdown: "# 个人长报告\n\n## 摘要\n\n事业方向保持观察。\n",
|
||||
generatedAt: "2026-09-28T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(safeParseReportDocument(document).ok, true);
|
||||
assert.equal(safeParseReportDocument(withReportBirthFingerprint(document, "a".repeat(32))).ok, true);
|
||||
assert.equal(safeParseReportDocument({ ...document, subject: { ...document.subject, birthFingerprint: "1994-05-18" } }).ok, false);
|
||||
assert.equal(withReportBirthFingerprint(document, undefined), document, "no fingerprint → untouched");
|
||||
});
|
||||
|
||||
test("coverSubject: only a matching fingerprint yields the subject id, self when chart_profile_id is null", () => {
|
||||
const fp = "a".repeat(32);
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: fp }), "self");
|
||||
assert.equal(reportCoverSubject({ chartProfileId: "11111111-1111-4111-8111-111111111111", storedFingerprint: fp, currentFingerprint: fp }), "11111111-1111-4111-8111-111111111111");
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: "b".repeat(32) }), null);
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: null, currentFingerprint: fp }), null);
|
||||
assert.equal(reportCoverSubject({ chartProfileId: null, storedFingerprint: fp, currentFingerprint: null }), null);
|
||||
});
|
||||
|
||||
test("GET /api/reports/:id compares on the server through the chart page's subject loader", () => {
|
||||
assert.match(itemRouteSource, /loadCurrentBirthFingerprint: async \(ownerId: string, subjectId: string\) => \(\s*subjectBirthFingerprint\(\(await loadSubjectBirth\(supabase, ownerId, subjectId\)\)\.row\)/);
|
||||
assert.match(routeCoreSource, /reportDocumentBirthFingerprint\(row\.reportDocument\)/);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page.tsx";
|
||||
import type { BirthSkyPaletteName } from "../src/lib/birth-sky/draw.ts";
|
||||
import { loadReportSkyCover } from "../src/lib/birth-sky/report-cover.ts";
|
||||
import { goldenSky } from "./birth-sky-test-support.ts";
|
||||
|
||||
// Report cover (TASK-birth-sky-followup-20260928 F3).
|
||||
|
||||
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/birth-sky/report-sky-cover.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/components/birth-sky/report-sky-cover.css", import.meta.url), "utf8");
|
||||
|
||||
function readyEnvelope(coverSubject?: unknown) {
|
||||
return {
|
||||
report: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "ready",
|
||||
createdAt: "2026-09-28T00:00:00.000Z",
|
||||
...(coverSubject === undefined ? {} : { coverSubject }),
|
||||
},
|
||||
longformMarkdown: "# 报告\n\n正文。",
|
||||
};
|
||||
}
|
||||
|
||||
test("matching fingerprint → the ready state carries coverSubject; mismatch or legacy → no cover", () => {
|
||||
const bound = classifyReportEnvelope(200, readyEnvelope("self"));
|
||||
assert.equal(bound.phase, "markdown-ready");
|
||||
assert.equal(bound.phase === "markdown-ready" && bound.coverSubject, "self");
|
||||
for (const value of [null, undefined, "", 42]) {
|
||||
const state = classifyReportEnvelope(200, readyEnvelope(value));
|
||||
assert.equal(state.phase, "markdown-ready");
|
||||
assert.equal("coverSubject" in state, false, `coverSubject=${String(value)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the cover sits above the body, lazy, and only when coverSubject is set", () => {
|
||||
assert.match(page, /\{state\.coverSubject \? <ReportSkyCover subjectId=\{state\.coverSubject\} \/> : null\}\s*<PersonalReportMarkdownView/);
|
||||
assert.match(page, /const ReportSkyCover = dynamic\(\s*\(\) => import\("@\/components\/birth-sky\/report-sky-cover"\)/);
|
||||
// No buttons, no placeholder, no waiting state.
|
||||
assert.doesNotMatch(component, /<Button|<button|Spinner|skeleton|正在/i);
|
||||
assert.match(component, /if \(!images\) return null;/, "nothing renders before both pictures are painted");
|
||||
});
|
||||
|
||||
test("loader paints night for the screen and paper for print with the same sky", async () => {
|
||||
const sky = goldenSky("day");
|
||||
const painted: BirthSkyPaletteName[] = [];
|
||||
const asked: string[] = [];
|
||||
const pngs = await loadReportSkyCover("self", new AbortController().signal, {
|
||||
fetchSky: async (subjectId) => { asked.push(subjectId); return sky; },
|
||||
render: async (_sky, palette) => { painted.push(palette); return new Blob([palette]); },
|
||||
});
|
||||
assert.deepEqual(asked, ["self"]);
|
||||
assert.deepEqual(painted.sort(), ["night", "paper"]);
|
||||
assert.equal(await pngs?.screen.text(), "night");
|
||||
assert.equal(await pngs?.print.text(), "paper");
|
||||
assert.match(pngs?.description ?? "", /^星星都在,只是还看不见。|太阳/);
|
||||
});
|
||||
|
||||
test("fetch failure, an empty sky or an abort → no cover, and nothing throws into the report", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
const render = async () => new Blob(["x"]);
|
||||
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => { throw new Error("offline"); }, render }), null);
|
||||
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => null, render }), null);
|
||||
assert.equal(await loadReportSkyCover("self", signal, { fetchSky: async () => goldenSky("day"), render: async () => { throw new Error("canvas"); } }), null);
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
assert.equal(await loadReportSkyCover("self", aborted.signal, { fetchSky: async () => goldenSky("day"), render }), null);
|
||||
});
|
||||
|
||||
test("print: paper picture alone on the first A4 page; the screen picture is hidden", () => {
|
||||
assert.match(component, /className="report-sky-cover-print" src=\{images\.print\}/);
|
||||
assert.match(css, /\.report-sky-cover-print \{ display: none !important; \}/);
|
||||
const print = css.slice(css.indexOf("@media print"));
|
||||
assert.match(print, /\.report-sky-cover \{[^}]*break-after: page;/);
|
||||
assert.match(print, /\.report-sky-cover-screen \{ display: none !important; \}/);
|
||||
assert.match(print, /\.report-sky-cover-print \{[^}]*display: block !important;/);
|
||||
assert.match(css, /aspect-ratio: 3 \/ 4;/);
|
||||
});
|
||||
Reference in New Issue
Block a user