← back to Rentv
test(deals): extract pull-deals field parsers to testable lib/deal-parse.mjs + add 18-case regression suite locking in cycle 28-31 fixes (parseAddress/typeOf/txnOf/parseLocation); wire into npm test
8f0382953c7df3d5ea2e0fc996b3df835633ae20 · 2026-08-06 07:26:12 -0700 · Steve
Files touched
M package.jsonA scripts/lib/deal-parse.mjsM scripts/pull-deals.mjsA test/deals/parse.test.mjs
Diff
commit 8f0382953c7df3d5ea2e0fc996b3df835633ae20
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 07:26:12 2026 -0700
test(deals): extract pull-deals field parsers to testable lib/deal-parse.mjs + add 18-case regression suite locking in cycle 28-31 fixes (parseAddress/typeOf/txnOf/parseLocation); wire into npm test
---
package.json | 2 +-
scripts/lib/deal-parse.mjs | 92 +++++++++++++++++++++++++++++++
scripts/pull-deals.mjs | 96 +++-----------------------------
test/deals/parse.test.mjs | 134 +++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 234 insertions(+), 90 deletions(-)
diff --git a/package.json b/package.json
index 02ad87a3..fda8c315 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"pr:migrate": "node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"",
"pr:worker": "node src/pr/worker.js",
"pr:seed:ca": "node src/pr/seed/ca-seed.js",
- "test": "node --test test/pr/*.test.js",
+ "test": "node --test test/pr/*.test.js test/deals/*.test.mjs",
"pr:seed:az": "node src/pr/seed/az-seed.js",
"pr:seed:national": "node src/pr/seed/national-media-seed.js",
"pr:crawl:media": "node src/pr/tools/daily-media-crawl.js"
diff --git a/scripts/lib/deal-parse.mjs b/scripts/lib/deal-parse.mjs
new file mode 100644
index 00000000..06c74b6c
--- /dev/null
+++ b/scripts/lib/deal-parse.mjs
@@ -0,0 +1,92 @@
+// Shared, PURE deal-field extractors — the structured-field parsers that turn a rentv.com
+// transaction story (title + fetched body) into deal-card fields. Extracted verbatim from
+// pull-deals.mjs (2026-08-06) so they are importable + unit-tested in isolation, with NO
+// side effects (no fetch, no fs, no top-level execution) — pull-deals.mjs imports them and
+// keeps the fetch/localize/registry orchestration. Location parsing lives in ./parse-location.mjs.
+//
+// Every function is honest-empty: a field it can't confidently find returns null, never a guess.
+
+// $51.8 mil / $1.2 bil / $985,000 → normalized number of dollars
+export function parseAmount(text) {
+ let m = text.match(/\$\s?([\d,]+(?:\.\d+)?)\s?(bil|billion|mil|million|k)?/i);
+ if (!m) return { amount: null, amount_label: null };
+ let n = parseFloat(m[1].replace(/,/g, ''));
+ const unit = (m[2] || '').toLowerCase();
+ if (/bil/.test(unit)) n *= 1e9; else if (/mil/.test(unit)) n *= 1e6; else if (unit === 'k') n *= 1e3;
+ const label = n >= 1e9 ? `$${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n / 1e6).toFixed(1)}M` : `$${n.toLocaleString()}`;
+ return { amount: Math.round(n), amount_label: label };
+}
+
+// Classify from the TITLE first (it states the deal precisely); only fall back to
+// the body when the title is inconclusive — bodies leak generic words ("loan",
+// "units") that mis-drive the type of an office/retail deal.
+export function txnOf(t) {
+ return /refinanc|\brefi\b|obtains? (a )?(new )?loan|senior loan|recap|bridge loan/.test(t) ? 'Financing'
+ : /\bsold\b|sells|\bsale\b|acquir|buys|purchas|trades|closes on|pays|spends|snaps up|picks up|fetches|changes hands|nets \$|lands \$|works out to/.test(t) ? 'Sale'
+ : /leas|tenant|renew|signs? (a|new)|inks? a/.test(t) ? 'Lease'
+ : /break(s)? ground|starts? (?:work on|construction)|begins? (?:work on|construction)|kicks? off construction|develop|deliver|top(s|ped) out|complet|construction|to build|unveils?|plan(s|ned) to|underway|rises|proposes?/.test(t) ? 'Development'
+ : null;
+}
+export function typeOf(t) {
+ // "tower"/"high-rise" are building-FORM words (a tower can be residential OR office), not use-types,
+ // so they must NOT outrank an explicit use signal — "Granite Towers Equity Group buys a 229-unit
+ // multifamily asset" is Multifamily, not Office (the firm name carries "Towers"). They live in a
+ // last-resort Office fallback below, after every explicit use-type. A unit count ("229-unit") is a
+ // reliable residential tell (office/industrial/retail are measured in SF, never units) → Multifamily.
+ return /office|\bhq\b/.test(t) ? 'Office'
+ : /industrial|warehouse|logistics|distribution|\bflex\b/.test(t) ? 'Industrial'
+ : /retail|shopping|mall|grocery|storefront|strip (center|mall)/.test(t) ? 'Retail'
+ : /hotel|hospitality|resort|motel/.test(t) ? 'Hospitality'
+ : /medical|life science|\blab\b|biotech/.test(t) ? 'Medical/Life Science'
+ : /mixed-?use/.test(t) ? 'Mixed-Use'
+ : /self-?storage/.test(t) ? 'Self-Storage'
+ : /multifamily|apartment|\bres\b|residential communit|\d[\d,]*[- ]units?\b|senior[- ]?(?:housing|living|apartments)|independent[- ]?living|assisted[- ]?living|memory[- ]?care|skilled[- ]?nursing|convalescent/.test(t) ? 'Multifamily'
+ : /\bland\b|\bsite\b|\bacres?\b/.test(t) ? 'Land'
+ : /high-?rise|tower/.test(t) ? 'Office'
+ : null;
+}
+export function classify(title, body) {
+ const tl = title.toLowerCase(), bl = body.toLowerCase();
+ const txn = txnOf(tl) || txnOf(bl) || 'Deal';
+ // for type, allow a bare "unit(s)" body signal only as a last resort
+ const type = typeOf(tl) || typeOf(bl) || (/\bunit(s)?\b/.test(bl) ? 'Multifamily' : 'Commercial');
+ return { txn, type };
+}
+
+export function parseSize(text) {
+ const units = text.match(/([\d,]+)[- ]unit/i);
+ const sf = text.match(/([\d,]+)\s?(?:sf|sq\.?\s?ft|square[ -]feet|square[ -]foot)/i);
+ const acres = text.match(/([\d,.]+)[- ]acre/i);
+ const parts = [];
+ if (units) parts.push(`${units[1]} units`);
+ if (sf) parts.push(`${sf[1]} SF`);
+ if (acres) parts.push(`${acres[1]} acres`);
+ return { size_label: parts.join(' · ') || null,
+ units: units ? +units[1].replace(/,/g, '') : null,
+ sqft: sf ? +sf[1].replace(/,/g, '') : null };
+}
+
+export function parseAddress(body) {
+ // Two anchors keep this precise: (1) the bare "at" cue is word-boundary-anchored (\bat) so it
+ // only fires on "at" as a standalone word, not the trailing "at" inside format/that/flat/combat
+ // (which false-extracted addresses from ordinary prose, e.g. "the new format 1200 Broadway St").
+ // (2) the street-suffix group is trailed by \b so a short suffix like "St"/"Ave" matches only as a
+ // whole token — without it the lazy name class stopped at the "St" INSIDE the street name itself
+ // ("1200 North State Street" → corrupted "1200 North St"; "300 Stewart Street" → "300 Stewart St").
+ // Dropping the old optional-period capture avoids trailing sentence punctuation now that suffixes
+ // match in full. A suffix not in this list (e.g. Plaza) now yields null (honest-empty) over a corrupt abbrev.
+ const m = body.match(/(?:located at|situated at|address is|\bat)\s+(\d{2,6}\s[A-Z][A-Za-z0-9.\- ]+?(?:Rd|Road|St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Way|Ln|Lane|Pl|Place|Ct|Court|Pkwy|Parkway|Hwy|Highway)\b)/);
+ return m ? m[1].trim() : null;
+}
+export function parseOccupancy(body) { const m = body.match(/(\d{1,3})%\s?(?:leased|occup)/i); return m ? +m[1] : null; }
+export function parseYearBuilt(body) { const m = body.match(/built in (\d{4})/i); return m ? +m[1] : null; }
+
+// two-sentence summary from the body (drop the leading title echo + date stamp)
+export function summarize(title, body) {
+ let b = body;
+ const ti = b.indexOf(title);
+ if (ti >= 0) b = b.slice(ti + title.length);
+ b = b.replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim();
+ const sentences = b.match(/[^.!?]+[.!?]+/g) || [b];
+ return sentences.slice(0, 2).join(' ').trim().slice(0, 320);
+}
diff --git a/scripts/pull-deals.mjs b/scripts/pull-deals.mjs
index 57147feb..9c32bd55 100644
--- a/scripts/pull-deals.mjs
+++ b/scripts/pull-deals.mjs
@@ -29,95 +29,13 @@ async function fetchDecoded(url) {
const clean = (t) => t.replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"')
.replace(/ | /g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
-// $51.8 mil / $1.2 bil / $985,000 → normalized number of dollars
-function parseAmount(text) {
- let m = text.match(/\$\s?([\d,]+(?:\.\d+)?)\s?(bil|billion|mil|million|k)?/i);
- if (!m) return { amount: null, amount_label: null };
- let n = parseFloat(m[1].replace(/,/g, ''));
- const unit = (m[2] || '').toLowerCase();
- if (/bil/.test(unit)) n *= 1e9; else if (/mil/.test(unit)) n *= 1e6; else if (unit === 'k') n *= 1e3;
- const label = n >= 1e9 ? `$${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n / 1e6).toFixed(1)}M` : `$${n.toLocaleString()}`;
- return { amount: Math.round(n), amount_label: label };
-}
-
-// Classify from the TITLE first (it states the deal precisely); only fall back to
-// the body when the title is inconclusive — bodies leak generic words ("loan",
-// "units") that mis-drive the type of an office/retail deal.
-function txnOf(t) {
- return /refinanc|\brefi\b|obtains? (a )?(new )?loan|senior loan|recap|bridge loan/.test(t) ? 'Financing'
- : /\bsold\b|sells|\bsale\b|acquir|buys|purchas|trades|closes on|pays|spends|snaps up|picks up|fetches|changes hands|nets \$|lands \$|works out to/.test(t) ? 'Sale'
- : /leas|tenant|renew|signs? (a|new)|inks? a/.test(t) ? 'Lease'
- : /break(s)? ground|starts? (?:work on|construction)|begins? (?:work on|construction)|kicks? off construction|develop|deliver|top(s|ped) out|complet|construction|to build|unveils?|plan(s|ned) to|underway|rises|proposes?/.test(t) ? 'Development'
- : null;
-}
-function typeOf(t) {
- // "tower"/"high-rise" are building-FORM words (a tower can be residential OR office), not use-types,
- // so they must NOT outrank an explicit use signal — "Granite Towers Equity Group buys a 229-unit
- // multifamily asset" is Multifamily, not Office (the firm name carries "Towers"). They live in a
- // last-resort Office fallback below, after every explicit use-type. A unit count ("229-unit") is a
- // reliable residential tell (office/industrial/retail are measured in SF, never units) → Multifamily.
- return /office|\bhq\b/.test(t) ? 'Office'
- : /industrial|warehouse|logistics|distribution|\bflex\b/.test(t) ? 'Industrial'
- : /retail|shopping|mall|grocery|storefront|strip (center|mall)/.test(t) ? 'Retail'
- : /hotel|hospitality|resort|motel/.test(t) ? 'Hospitality'
- : /medical|life science|\blab\b|biotech/.test(t) ? 'Medical/Life Science'
- : /mixed-?use/.test(t) ? 'Mixed-Use'
- : /self-?storage/.test(t) ? 'Self-Storage'
- : /multifamily|apartment|\bres\b|residential communit|\d[\d,]*[- ]units?\b|senior[- ]?(?:housing|living|apartments)|independent[- ]?living|assisted[- ]?living|memory[- ]?care|skilled[- ]?nursing|convalescent/.test(t) ? 'Multifamily'
- : /\bland\b|\bsite\b|\bacres?\b/.test(t) ? 'Land'
- : /high-?rise|tower/.test(t) ? 'Office'
- : null;
-}
-function classify(title, body) {
- const tl = title.toLowerCase(), bl = body.toLowerCase();
- const txn = txnOf(tl) || txnOf(bl) || 'Deal';
- // for type, allow a bare "unit(s)" body signal only as a last resort
- const type = typeOf(tl) || typeOf(bl) || (/\bunit(s)?\b/.test(bl) ? 'Multifamily' : 'Commercial');
- return { txn, type };
-}
-
-// Location parsing now lives in ./lib/parse-location.mjs (shared + unit-tested).
-// The old inline version fell back to a bare KNOWN_CITY match in the body, which
-// grabbed a firm's HQ city ("Phoenix Capital Management" → "Phoenix") instead of
-// the asset location — fixed 2026-07-24 by preferring in-text asset cues.
-
-function parseSize(text) {
- const units = text.match(/([\d,]+)[- ]unit/i);
- const sf = text.match(/([\d,]+)\s?(?:sf|sq\.?\s?ft|square[ -]feet|square[ -]foot)/i);
- const acres = text.match(/([\d,.]+)[- ]acre/i);
- const parts = [];
- if (units) parts.push(`${units[1]} units`);
- if (sf) parts.push(`${sf[1]} SF`);
- if (acres) parts.push(`${acres[1]} acres`);
- return { size_label: parts.join(' · ') || null,
- units: units ? +units[1].replace(/,/g, '') : null,
- sqft: sf ? +sf[1].replace(/,/g, '') : null };
-}
-
-function parseAddress(body) {
- // Two anchors keep this precise: (1) the bare "at" cue is word-boundary-anchored (\bat) so it
- // only fires on "at" as a standalone word, not the trailing "at" inside format/that/flat/combat
- // (which false-extracted addresses from ordinary prose, e.g. "the new format 1200 Broadway St").
- // (2) the street-suffix group is trailed by \b so a short suffix like "St"/"Ave" matches only as a
- // whole token — without it the lazy name class stopped at the "St" INSIDE the street name itself
- // ("1200 North State Street" → corrupted "1200 North St"; "300 Stewart Street" → "300 Stewart St").
- // Dropping the old optional-period capture avoids trailing sentence punctuation now that suffixes
- // match in full. A suffix not in this list (e.g. Plaza) now yields null (honest-empty) over a corrupt abbrev.
- const m = body.match(/(?:located at|situated at|address is|\bat)\s+(\d{2,6}\s[A-Z][A-Za-z0-9.\- ]+?(?:Rd|Road|St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Way|Ln|Lane|Pl|Place|Ct|Court|Pkwy|Parkway|Hwy|Highway)\b)/);
- return m ? m[1].trim() : null;
-}
-function parseOccupancy(body) { const m = body.match(/(\d{1,3})%\s?(?:leased|occup)/i); return m ? +m[1] : null; }
-function parseYearBuilt(body) { const m = body.match(/built in (\d{4})/i); return m ? +m[1] : null; }
-
-// two-sentence summary from the body (drop the leading title echo + date stamp)
-function summarize(title, body) {
- let b = body;
- const ti = b.indexOf(title);
- if (ti >= 0) b = b.slice(ti + title.length);
- b = b.replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim();
- const sentences = b.match(/[^.!?]+[.!?]+/g) || [b];
- return sentences.slice(0, 2).join(' ').trim().slice(0, 320);
-}
+// Pure deal-field extractors (parseAmount / classify / parseSize / parseAddress / …) now live in
+// ./lib/deal-parse.mjs so they are unit-testable in isolation (test/deals/parse.test.js). Location
+// parsing lives in ./lib/parse-location.mjs. This script keeps only the fetch/localize/registry
+// orchestration below.
+import {
+ parseAmount, classify, parseSize, parseAddress, parseOccupancy, parseYearBuilt, summarize,
+} from './lib/deal-parse.mjs';
try {
const news = JSON.parse(readFileSync(join(DATA, 'news.json'), 'utf8'));
diff --git a/test/deals/parse.test.mjs b/test/deals/parse.test.mjs
new file mode 100644
index 00000000..4bb32a4d
--- /dev/null
+++ b/test/deals/parse.test.mjs
@@ -0,0 +1,134 @@
+// Regression suite for the deal-field parsers (scripts/lib/deal-parse.mjs + parse-location.mjs).
+// Locks in the /yoloforever cycle 28–31 fixes so a future edit can't silently reintroduce a
+// misparse. Run: `node --test test/deals/parse.test.mjs` (or `npm test`).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ parseAmount, txnOf, typeOf, classify, parseSize,
+ parseAddress, parseOccupancy, parseYearBuilt,
+} from '../../scripts/lib/deal-parse.mjs';
+import { parseLocation } from '../../scripts/lib/parse-location.mjs';
+
+// ── parseAddress (cycle 28) ───────────────────────────────────────────────────
+test('parseAddress: bare "at" is word-boundary-anchored (no false extraction from prose)', () => {
+ assert.equal(parseAddress('the new format 1200 Broadway Street layout was unveiled'), null);
+ assert.equal(parseAddress('the firm noted that 500 Madison Avenue analysts met'), null);
+ assert.equal(parseAddress('a flat 400 Spring Street rate applied'), null);
+});
+test('parseAddress: real cues still extract', () => {
+ assert.equal(parseAddress('The property is located at 250 Park Avenue.'), '250 Park Avenue');
+ assert.equal(parseAddress('Situated at 88 Kearny Street, the tower sold.'), '88 Kearny Street');
+ assert.equal(parseAddress('The asset at 555 California Street traded.'), '555 California Street');
+});
+test('parseAddress: suffix \\b stops "St" truncating a street NAME', () => {
+ assert.equal(parseAddress('The tower at 1200 North State Street traded.'), '1200 North State Street');
+ assert.equal(parseAddress('Located at 300 Stewart Street, the asset sold.'), '300 Stewart Street');
+ // abbreviation with a trailing period still yields a clean "St"
+ assert.equal(parseAddress('The building at 123 Main St. sold.'), '123 Main St');
+ // a suffix not in the list (Plaza) → null (honest-empty), never a corrupt "Pl"
+ assert.equal(parseAddress('Deal at 30 Rockefeller Plaza pending.'), null);
+});
+
+// ── typeOf (cycle 30) ─────────────────────────────────────────────────────────
+test('typeOf: building-form "tower" does not outrank an explicit use-type', () => {
+ // firm name carries "Towers" but the asset is multifamily
+ assert.equal(typeOf('granite towers equity group buys 229-unit texas multifamily asset'), 'Multifamily');
+ // a unit count alone is a residential tell
+ assert.equal(typeOf('buyer acquires 300-unit tower downtown'), 'Multifamily');
+});
+test('typeOf: pure high-rise/tower with no use word still → Office (fallback)', () => {
+ assert.equal(typeOf('buys downtown la high-rise for $200m'), 'Office');
+ assert.equal(typeOf('obtains refi on office high-rise in san francisco'), 'Office');
+});
+test('typeOf: senior housing → Multifamily; "senior loan" is NOT senior housing', () => {
+ assert.equal(typeOf('oxnard independent living facility lands new owner'), 'Multifamily');
+ assert.equal(typeOf('buyer acquires assisted living community'), 'Multifamily');
+ assert.equal(typeOf('company secures senior loan on office tower'), 'Office');
+ assert.equal(typeOf('reit issues senior notes backed by retail portfolio'), 'Retail');
+});
+test('typeOf: explicit use-types classify correctly', () => {
+ assert.equal(typeOf('rancho bernardo office campus trades'), 'Office');
+ assert.equal(typeOf('1.1 msf industrial facility in phoenix'), 'Industrial');
+ assert.equal(typeOf('retail portfolio in downtown long beach'), 'Retail');
+ assert.equal(typeOf('200-unit self-storage facility'), 'Self-Storage'); // beats the unit-count → Multifamily
+ assert.equal(typeOf('nothing classifiable here'), null);
+});
+
+// ── txnOf (cycle 31) ──────────────────────────────────────────────────────────
+test('txnOf: groundbreaking synonyms → Development', () => {
+ assert.equal(txnOf('go industrial starts work on 1.1 msf industrial facility in phoenix'), 'Development');
+ assert.equal(txnOf('developer begins construction on 400-unit tower'), 'Development');
+ assert.equal(txnOf('construction gets underway at new campus'), 'Development');
+});
+test('txnOf: kicks-off/starts-building metaphors are NOT Development (defer to body)', () => {
+ assert.equal(txnOf('owner kicks off marketing of downtown tower'), null);
+ assert.equal(txnOf('firm starts building its national portfolio'), null);
+});
+test('txnOf: core verbs classify + precedence holds', () => {
+ assert.equal(txnOf('blackrock entity acquires 3,620-unit multifamily portfolio'), 'Sale');
+ assert.equal(txnOf('the swig company obtains refi on office high-rise'), 'Financing');
+ assert.equal(txnOf('velo3d leases 289k sf bay area facility'), 'Lease');
+ assert.equal(txnOf('company starts leasing at new campus'), 'Lease'); // Lease precedes Development
+});
+
+// ── parseAmount ───────────────────────────────────────────────────────────────
+test('parseAmount: units, commas, and label formatting', () => {
+ assert.deepEqual(parseAmount('$34.6 mil'), { amount: 34600000, amount_label: '$34.6M' });
+ assert.deepEqual(parseAmount('$1.2 billion'), { amount: 1200000000, amount_label: '$1.20B' });
+ assert.deepEqual(parseAmount('$985,000'), { amount: 985000, amount_label: '$985,000' });
+ assert.deepEqual(parseAmount('$500K'), { amount: 500000, amount_label: '$500,000' });
+ assert.deepEqual(parseAmount('no dollars here'), { amount: null, amount_label: null });
+});
+
+// ── parseSize ─────────────────────────────────────────────────────────────────
+test('parseSize: units / sf / acres', () => {
+ assert.deepEqual(parseSize('293-unit res property'), { size_label: '293 units', units: 293, sqft: null });
+ assert.deepEqual(parseSize('538,000 sf building'), { size_label: '538,000 SF', units: null, sqft: 538000 });
+ const acres = parseSize('12.5-acre site');
+ assert.equal(acres.size_label, '12.5 acres');
+ assert.equal(parseSize('nothing measurable').size_label, null);
+});
+
+// ── parseOccupancy / parseYearBuilt ───────────────────────────────────────────
+test('parseOccupancy + parseYearBuilt', () => {
+ assert.equal(parseOccupancy('the asset is 95% leased'), 95);
+ assert.equal(parseOccupancy('100% occupied at closing'), 100);
+ assert.equal(parseOccupancy('no occupancy figure'), null);
+ assert.equal(parseYearBuilt('the property was built in 1998'), 1998);
+ assert.equal(parseYearBuilt('no vintage given'), null);
+});
+
+// ── classify (integration: title wins over a body leak) ───────────────────────
+test('classify: title signal beats a body leak', () => {
+ // groundbreaking title + body that mentions a land acquisition
+ assert.deepEqual(
+ classify('GO Industrial Starts Work on 1.1 msf Industrial Facility', 'The firm acquired the 40-acre site.'),
+ { txn: 'Development', type: 'Industrial' },
+ );
+ // multifamily title with "Towers" firm name + refi body
+ assert.deepEqual(
+ classify('Granite Towers Equity Group Buys 229-Unit Multifamily Asset', 'obtained a new loan'),
+ { txn: 'Sale', type: 'Multifamily' },
+ );
+});
+
+// ── parseLocation (cycle 29) ──────────────────────────────────────────────────
+test('parseLocation: explicit title state beats a body-leaked city', () => {
+ const r = parseLocation('Longpoint Partners Closes $34.6 Mil AZ Industrial Buy', 'active across the Inland Empire market in California.');
+ assert.equal(r.state, 'AZ');
+ assert.notEqual(r.city, 'Inland Empire');
+});
+test('parseLocation: "in <City>." does not swallow the next sentence', () => {
+ assert.deepEqual(
+ parseLocation('SFF Realty Buys 104k sf Silicon Valley Office/R&D Property', 'The asset in Sunnyvale. Located at 1200 Kifer Rd traded.'),
+ { city: 'Sunnyvale', state: 'CA' },
+ );
+});
+test('parseLocation: correctly-paired City, ST is left intact; full state names do not mis-fire', () => {
+ assert.deepEqual(parseLocation('Gilbert, AZ Multifamily Community Refinanced', ''), { city: 'Gilbert', state: 'AZ' });
+ assert.equal(parseLocation('Step Up Housing Acquires Two California Multifamily Communities', 'in Sacramento').state, 'CA');
+});
+test('parseLocation: ambiguous 2-letter tokens (OR/ID/CO) are not treated as states', () => {
+ assert.equal(parseLocation('Investor Buys OR Leases Phoenix Industrial Asset', 'the property in Phoenix').state, 'AZ');
+ assert.equal(parseLocation('Asset ID 44291 Trades in Phoenix', 'in Phoenix').state, 'AZ');
+});
← 9a19d50d auto-save: 2026-08-06T07:19:35 (7 files) — data/deals-regist
·
back to Rentv
·
harden(deals): Cody gate — add 5 summarize() tests (prod pat 9300439d →