← back to Homesonspec

collectors/harris-doyle/src/selftest.test.ts

188 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 { harrisDoyleAdapter, parseHome } from "./index";

/**
 * Bounded, read-only self-test for the Harris Doyle Homes adapter. Runs
 * `parseHome` + `harrisDoyleAdapter.extract()` over 14 fixtures captured
 * (rate-limited, honest UA) from harrisdoyle.com across AL + FL / 11 communities
 * on 2026-08-30, proves facts-only coverage, that addresses AND ids are
 * genuinely per-home (not aggregate), that honest-null fields stay null
 * (never fabricated), 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("harris-doyle adapter — coverage + per-home + honest-nulls + 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 + ids", () => {
    let n = 0;
    const cov = {
      price: 0, beds: 0, baths: 0, sqft: 0, address: 0, garage: 0, community: 0,
      plan: 0, lot: 0, id: 0, phone: 0, status: 0, geo: 0, stories: 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.garages != null) cov.garage++;
      if (h.community) cov.community++;
      if (h.planName) cov.plan++;
      if (h.lotNumber) cov.lot++;
      if (h.phone) cov.phone++;
      if (h.status) cov.status++;
      if (h.lat != null && h.lon != null) cov.geo++;
      if (h.stories != null) cov.stories++;
    }

    // eslint-disable-next-line no-console
    console.log(
      `\n[harris-doyle 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)}  garage=${pct(cov.garage, n)}  ` +
        `community=${pct(cov.community, n)}  plan=${pct(cov.plan, n)}  lot=${pct(cov.lot, n)}  ` +
        `id=${pct(cov.id, n)}  phone=${pct(cov.phone, n)}  status=${pct(cov.status, n)}  ` +
        `geo=${pct(cov.geo, n)}(honest-null)  stories=${pct(cov.stories, n)}(honest-null)`,
    );

    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 present on every Harris Doyle QMI.
    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.garage).toBe(n);
    expect(cov.community).toBe(n);
    expect(cov.plan).toBe(n);
    expect(cov.id).toBe(n);
    expect(cov.status).toBe(n);
    // Lot + phone present on the overwhelming majority; small slack for a page
    // that omits one.
    expect(cov.lot / n).toBeGreaterThanOrEqual(0.9);
    expect(cov.phone / n).toBeGreaterThanOrEqual(0.9);
    // HONEST NULLS — these are genuinely absent from the page and must NEVER be
    // fabricated: no per-home geo, no per-home stories field. Assert they stay 0.
    expect(cov.geo).toBe(0);
    expect(cov.stories).toBe(0);
  });

  it("canonicalHints.address is the REAL street, never a 'Lot X' fallback", () => {
    for (const file of files) {
      const html = readFileSync(join(FX, file), "utf8");
      const url = urls[file] ?? `fixture://${file}`;
      const page = mkPage(url, html);
      const { records } = harrisDoyleAdapter.extract(page);
      const home = records.find((r) => r.entityType === "inventory_home");
      expect(home).toBeDefined();
      const addr = home!.canonicalHints.address ?? "";
      // Must be a real street (has a number + a street word) and NOT a "Lot N"
      // placeholder and NOT the page URL fallback.
      expect(addr).not.toMatch(/^lot\b/i);
      expect(addr).not.toMatch(/^https?:\/\//);
      expect(addr).toMatch(/\d/); // real street addresses carry a house number
      // And it must equal the parsed street exactly (no ternary drift).
      const h = parseHome(html, url);
      expect(addr).toBe(h!.street);
    }
  });

  it("uses only explicit pageType status evidence", () => {
    const file = files[0]!;
    const html = readFileSync(join(FX, file), "utf8");
    const url = urls[file]!;
    expect(parseHome(html, url)?.status).toBe("MOVE_IN_READY");
    expect(parseHome(html.replace(/"pageType":"Move In Ready"/, '"pageType":"Unknown"'), url)?.status).toBeNull();
  });

  it("community names are naturally cased from the dataLayer (never slug-mangled prepositions)", () => {
    // The dead entity-encoded-href regex used to fall back to a slug titlecaser
    // that produced "The Foothills At Blackridge" (wrong). The dataLayer carries
    // the correct case ("…at…"). Assert no community carries a title-cased
    // joining word — proves the dataLayer path, not the slug fallback, is winning.
    for (const file of files) {
      const html = readFileSync(join(FX, file), "utf8");
      const url = urls[file] ?? `fixture://${file}`;
      const community = parseHome(html, url)?.community ?? "";
      // Whitespace-delimited so a legitimately-capitalized leading "The " is not
      // flagged — only a MID-NAME title-cased joining word (the slug-mangle bug).
      expect(community, `mis-cased preposition in "${community}" @ ${file}`).not.toMatch(
        /\s(At|Of|In|The|And|On|By|For)\s/,
      );
    }
  });

  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 = mkPage(url, html);
      const { records, errors } = harrisDoyleAdapter.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 mkPage(url: string, html: string) {
  return {
    url,
    retrievedAt: new Date().toISOString(),
    contentType: "text/html",
    body: Buffer.from(html),
    contentHash: "test",
  };
}

function pct(a: number, b: number): string {
  return b ? `${Math.round((100 * a) / b)}%` : "n/a";
}