← 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";
}