← back to Homesonspec
collectors/goodall-homes/src/selftest.test.ts
123 lines
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { validatePayload } from "@homesonspec/schemas";
import { goodallHomesAdapter, parseHome } from "./index";
/**
* Bounded, read-only self-test for the Goodall Homes adapter. Runs `parseHome` +
* `goodallHomesAdapter.extract()` over 14 fixtures captured (rate-limited,
* honest UA) from goodallhomes.com across TN + KY / 5 communities on 2026-08-12,
* proves facts-only coverage, that addresses are genuinely per-home (not
* aggregate), and that every emitted record validates against the published
* schema.
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const FX = join(HERE, "..", "fixtures", "homes");
function urlMap(): Record<string, string> {
const tsv = readFileSync(join(FX, "_urls.tsv"), "utf8");
const out: Record<string, string> = {};
for (const line of tsv.split(/\r?\n/)) {
const [file, url] = line.split("\t");
if (file && url) out[file.trim()] = url.trim();
}
return out;
}
describe("goodall-homes adapter — coverage + per-home + schema", () => {
const files = readdirSync(FX).filter((f) => f.endsWith(".html")).sort();
const urls = urlMap();
it("parses per-home facts with high coverage and distinct addresses", () => {
let n = 0;
const cov = { price: 0, beds: 0, baths: 0, sqft: 0, address: 0, stories: 0, garage: 0, geo: 0, status: 0, id: 0 };
const addresses = new Set<string>();
const ids = new Set<string>();
for (const file of files) {
const html = readFileSync(join(FX, file), "utf8");
const url = urls[file] ?? `fixture://${file}`;
const h = parseHome(html, url);
if (!h) continue;
n++;
if (h.street) {
cov.address++;
addresses.add(`${h.street}, ${h.city}, ${h.state}`);
}
if (h.builderInventoryId) {
cov.id++;
ids.add(h.builderInventoryId);
}
if (h.price != null) cov.price++;
if (h.beds != null) cov.beds++;
if (h.bathsTotal != null) cov.baths++;
if (h.sqft != null) cov.sqft++;
if (h.stories != null) cov.stories++;
if (h.garages != null) cov.garage++;
if (h.lat != null && h.lon != null) cov.geo++;
if (h.status) cov.status++;
}
// eslint-disable-next-line no-console
console.log(
`\n[goodall-homes self-test] records=${n} distinctAddresses=${addresses.size} distinctIds=${ids.size}\n` +
` price=${pct(cov.price, n)} beds=${pct(cov.beds, n)} baths=${pct(cov.baths, n)} ` +
`sqft=${pct(cov.sqft, n)} address=${pct(cov.address, n)} stories=${pct(cov.stories, n)} ` +
`garage=${pct(cov.garage, n)} geo=${pct(cov.geo, n)} status=${pct(cov.status, n)}`,
);
expect(n).toBeGreaterThanOrEqual(13);
// Genuinely per-home: one distinct street address AND one distinct builder id
// per parsed record (proves detail pages, not floorplan/community aggregates).
expect(addresses.size).toBe(n);
expect(ids.size).toBe(n);
// Facts-only coverage — every core field is present on every Goodall QMI
// inventory home (address/price/beds/baths/sqft/geo all in the SSR DOM/JSON-LD).
expect(cov.address).toBe(n);
expect(cov.price).toBe(n);
expect(cov.beds).toBe(n);
expect(cov.baths).toBe(n);
expect(cov.sqft).toBe(n);
expect(cov.geo).toBe(n);
expect(cov.status).toBe(n);
// stories + garage come from the HomeDetails_stories strip — present on the
// overwhelming majority; allow a small slack for any home that omits them.
expect(cov.stories / n).toBeGreaterThanOrEqual(0.9);
expect(cov.garage / n).toBeGreaterThanOrEqual(0.9);
});
it("emits schema-valid community + inventory_home records", () => {
let homes = 0;
for (const file of files) {
const html = readFileSync(join(FX, file), "utf8");
const url = urls[file] ?? `fixture://${file}`;
const page = {
url,
retrievedAt: new Date().toISOString(),
contentType: "text/html",
body: Buffer.from(html),
contentHash: "test",
};
const { records, errors } = goodallHomesAdapter.extract(page);
expect(errors).toEqual([]);
for (const rec of records) {
if (rec.entityType === "inventory_home") homes++;
const result = validatePayload(rec);
if (!result.ok) {
// eslint-disable-next-line no-console
console.error(`validation failed for ${rec.entityType} @ ${url}`, result);
}
expect(result.ok).toBe(true);
}
}
expect(homes).toBeGreaterThanOrEqual(13);
});
});
function pct(a: number, b: number): string {
return b ? `${Math.round((100 * a) / b)}%` : "n/a";
}