From e5f7b37dabcc097c67048825104f12d3e48f54c6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:15:16 -0400 Subject: [PATCH 1/4] Reforms: the tracker feed reads its pages in a fixed row order Each paged tracker read now names a unique order column (bill_id for processed_bills, id for the rest). Without one, Postgres gave page 2 of processed_bills rows already on page 1, so about 418 of 1,418 status rows were lost on each load, and a different set each time. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 22 ++-- app/src/tests/fixtures/api/billFeedMocks.ts | 105 ++++++++++++++++++++ app/src/tests/unit/api/billFeed.test.ts | 41 ++++++++ 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 39db3083e..42ed3a68e 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -189,6 +189,13 @@ function totalRows(response: Response): number | undefined { async function trackerSelect( table: string, select: string, + /** + * A unique column that puts the rows in one fixed order ("bill_id"). + * With no order, the database can give each page its rows in another + * order, so the pages repeat some rows and drop others, and different + * ones on each load. + */ + order: string, /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ filter?: string ): Promise { @@ -199,7 +206,7 @@ async function trackerSelect( // Read page by page: one request stops at the cap and drops the rest. const rows: any[] = []; for (;;) { - const query = `select=${select}${filter ? `&${filter}` : ''}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; + const query = `select=${select}${filter ? `&${filter}` : ''}&order=${order}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, @@ -500,6 +507,7 @@ export async function fetchTrackerBillDistricts( const rows = await trackerSelect( 'reform_impacts', 'district_impacts', + 'id', `id=eq.${encodeURIComponent(billId)}` ); const raw = rows?.[0]?.district_impacts; @@ -595,19 +603,21 @@ function processedBillFor( export async function fetchTrackerBills(): Promise { const [research, impacts, processed, validations, baselines] = await Promise.all([ // Titled as the tracker shows them: an entry with no title reads as its bill number. - trackerSelect('research', '*').then((rows) => rows?.map(withTrackerTitle) ?? null), + trackerSelect('research', '*', 'id').then((rows) => rows?.map(withTrackerTitle) ?? null), trackerSelect( 'reform_impacts', - 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at' + 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at', + 'id' ), - trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url'), + trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url', 'bill_id'), trackerSelect( 'validation_metadata', - 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against' + 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against', + 'id' ), // The law each bill was compared with. Read on its own, so a tracker // without the column yet still serves the rest of the feed. - trackerSelect('reform_impacts', 'id,baseline_params').catch(() => null), + trackerSelect('reform_impacts', 'id,baseline_params', 'id').catch(() => null), ]); if (!research) { return null; diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 1c4dc74bb..c9bc23a47 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -45,3 +45,108 @@ export const NET_COST_DRIFT_ROWS: TrackerTableRows = { }, ], }; + +/** The most rows one tracker read returns, as the Supabase cap sets it. */ +export const TRACKER_PAGE_LIMIT = 1000; + +/** The tracker's bill-status table holds more rows than one read returns. */ +export const STATUS_TABLE_ROWS = 1418; + +/** The first column a PostgREST `order` names ("bill_id" in "bill_id.asc"). */ +const orderColumn = (order: string) => order.split(',')[0].split('.')[0]; + +const byColumn = (column: string) => (a: any, b: any) => + a[column] < b[column] ? -1 : a[column] > b[column] ? 1 : 0; + +/** + * A fetch that serves each tracker table a page at a time, as PostgREST + * does. A read that names an `order` gets its rows in that order. A read + * with none has no fixed row order: as on the live tracker, every page + * starts from the table's first rows again, so a later page repeats rows + * already read and the rest never arrive. + */ +export const mockPagedTrackerFetch = (rows: TrackerTableRows) => + vi.fn(async (url: string) => { + const { pathname, searchParams } = new URL(url); + const all = rows[pathname.split('/').pop() ?? ''] ?? []; + const order = searchParams.get('order'); + const offset = Number(searchParams.get('offset') ?? 0); + const limit = Number(searchParams.get('limit') ?? all.length); + const count = Math.max(0, Math.min(limit, all.length - offset)); + const page = order + ? [...all].sort(byColumn(orderColumn(order))).slice(offset, offset + count) + : all.slice(0, count); + return { + ok: true, + headers: new Headers({ + 'content-range': `${offset}-${offset + page.length - 1}/${all.length}`, + }), + json: async () => page, + }; + }); + +/** WV SB 392, whose one status row is the bill-status table's last. */ +export const LATE_STATUS_BILL = { + id: 'wv-sb392', + state: 'WV', + type: 'bill', + title: 'WV SB 392', +} as const; +export const LATE_STATUS = { + status: 'Signed into Law', + legiscanUrl: 'https://legiscan.com/WV/bill/SB392/2026', +} as const; + +/** A full bill-status table with WV SB 392's row past the first page. */ +export const LATE_STATUS_ROWS: TrackerTableRows = { + research: [LATE_STATUS_BILL], + processed_bills: Array.from({ length: STATUS_TABLE_ROWS }, (_, index) => + index === STATUS_TABLE_ROWS - 1 + ? { + bill_id: 2_000_000 + index, + state: 'WV', + bill_number: 'SB392', + status: LATE_STATUS.status, + legiscan_url: LATE_STATUS.legiscanUrl, + } + : { + bill_id: 2_000_000 + index, + state: 'OK', + bill_number: `HB${1000 + index}`, + status: 'Introduced', + } + ), +}; + +/** Bills enough that every tracker table runs past one page. */ +export const MANY_BILLS = TRACKER_PAGE_LIMIT + 1; +export const MANY_BILLS_STATUS = 'In Committee'; + +const MANY_BILL_NUMBERS = Array.from({ length: MANY_BILLS }, (_, index) => 1000 + index); + +/** Every table past one page: each bill has its entry, run, check and status row. */ +export const MANY_BILL_ROWS: TrackerTableRows = { + research: MANY_BILL_NUMBERS.map((number) => ({ + id: `ok-hb${number}`, + state: 'OK', + type: 'bill', + title: `OK HB ${number}`, + })), + reform_impacts: MANY_BILL_NUMBERS.map((number) => ({ + id: `ok-hb${number}`, + computed: true, + budgetary_impact: { stateRevenueImpact: -number * 1000 }, + })), + validation_metadata: MANY_BILL_NUMBERS.map((number) => ({ + id: `ok-hb${number}`, + pe_estimate: -number * 1000, + fiscal_note_estimate: -number * 1000, + within_range: true, + })), + processed_bills: MANY_BILL_NUMBERS.map((number) => ({ + bill_id: 3_000_000 + number, + state: 'OK', + bill_number: `HB ${number}`, + status: MANY_BILLS_STATUS, + })), +}; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index fd0c4da9b..b49b317d2 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -9,6 +9,12 @@ import { } from '@/api/billFeed'; import { CURRENT_YEAR } from '@/constants'; import { + LATE_STATUS, + LATE_STATUS_ROWS, + MANY_BILL_ROWS, + MANY_BILLS, + MANY_BILLS_STATUS, + mockPagedTrackerFetch, mockTrackerFetch, NET_COST_DRIFT_ROWS, NET_COST_ONLY_ROWS, @@ -300,6 +306,41 @@ describe('tracker reads', () => { '1000', ]); }); + + test('given a status row past the first page then every load finds its stage and link', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + // Pages read with no fixed order repeat rows and skip the rest. + vi.stubGlobal('fetch', mockPagedTrackerFetch(LATE_STATUS_ROWS)); + + const [bill] = (await fetchTrackerBills())!; + + expect([bill.status, bill.legiscanUrl]).toEqual([LATE_STATUS.status, LATE_STATUS.legiscanUrl]); + }); + + test('given every table past one page then each bill reads once, with its run, check and stage', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + vi.stubGlobal('fetch', mockPagedTrackerFetch(MANY_BILL_ROWS)); + + const bills = (await fetchTrackerBills())!; + + const billsWith = (has: (bill: (typeof bills)[number]) => boolean) => + new Set(bills.filter(has).map((bill) => bill.id)).size; + expect({ + listed: bills.length, + unique: billsWith(() => true), + withRun: billsWith((bill) => bill.impacts?.revenue !== undefined), + withCheck: billsWith((bill) => bill.validation?.withinRange === true), + withStage: billsWith((bill) => bill.status === MANY_BILLS_STATUS), + }).toEqual({ + listed: MANY_BILLS, + unique: MANY_BILLS, + withRun: MANY_BILLS, + withCheck: MANY_BILLS, + withStage: MANY_BILLS, + }); + }); }); describe('fetchTrackerBills', () => { From 9984e07daf888e45d72e1e6100b6fd96369b6274 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 18:15:41 -0400 Subject: [PATCH 2/4] Reforms: a bill filed twice under one number keeps the later bill's stage When two processed_bills rows share one normalized bill number (NY "S 4487" and "S04487"), the feed kept whichever row it read last. It now keeps the row with the higher LegiScan bill id, the later bill, so the stage and the bill text link no longer depend on the read order. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 31 +++++++++++++++++++-- app/src/tests/fixtures/api/billFeedMocks.ts | 23 +++++++++++++++ app/src/tests/unit/api/billFeed.test.ts | 23 +++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 42ed3a68e..b6dd0c460 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -568,6 +568,31 @@ function normalizeBillNumber(value: string): string { const processedBillKey = (state: string, billNumber: string) => `${state.toUpperCase()}:${normalizeBillNumber(billNumber)}`; +/** A status row's LegiScan bill id as a number, lowest when it has none. */ +const billAge = (bill: any): number => { + const id = Number(bill.bill_id ?? NaN); + return Number.isFinite(id) ? id : -Infinity; +}; + +const statusRowText = (bill: any) => + JSON.stringify([bill.bill_id, bill.state, bill.bill_number, bill.status, bill.legiscan_url]); + +/** + * Sorts status rows so the later bill comes last. LegiScan numbers bills + * in the order it first sees them, so the higher bill id is the later + * bill; rows with the same id, or none, fall back to their contents. + * Two rows under one bill number (NY "S 4487" and "S04487") then resolve + * to the same row whatever order the tracker sent them in. + */ +function byLaterBill(a: any, b: any): number { + const [ageA, ageB] = [billAge(a), billAge(b)]; + if (ageA !== ageB) { + return ageA < ageB ? -1 : 1; + } + const [textA, textB] = [statusRowText(a), statusRowText(b)]; + return textA < textB ? -1 : textA > textB ? 1 : 0; +} + /** * The tracker's status row (processed_bills) for a research entry. The * entry's LegiScan link (legiscan_bill_id) names the row when it is set. @@ -626,13 +651,15 @@ export async function fetchTrackerBills(): Promise { const impactsById = new Map((impacts ?? []).map((impact) => [impact.id, impact])); const baselineById = new Map((baselines ?? []).map((row) => [row.id, row.baseline_params])); const validationById = new Map((validations ?? []).map((row) => [row.id, row])); + // The later bill last, so it is the row each map keeps for a shared key. + const processedRows = [...(processed ?? [])].sort(byLaterBill); const processedByBillId = new Map( - (processed ?? []).flatMap((bill) => + processedRows.flatMap((bill) => bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), bill]] ) ); const processedByKey = new Map( - (processed ?? []).map((bill) => [ + processedRows.map((bill) => [ processedBillKey(String(bill.state), String(bill.bill_number)), bill, ]) diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index c9bc23a47..5247f83a6 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -150,3 +150,26 @@ export const MANY_BILL_ROWS: TrackerTableRows = { status: MANY_BILLS_STATUS, })), }; + +/** NY S 4487, filed twice in the status table under two spellings of its number. */ +export const TWICE_FILED_BILL = { + id: 'ny-s-4487', + state: 'NY', + type: 'bill', + title: 'NY S 4487', +} as const; +/** The later of the two rows: LegiScan gives a later bill a higher id. */ +export const TWICE_FILED_LATER = { + bill_id: 1_987_654, + state: 'NY', + bill_number: 'S04487', + status: 'In Committee', + legiscan_url: 'https://legiscan.com/NY/bill/S04487/2025', +} as const; +export const TWICE_FILED_EARLIER = { + bill_id: 1_765_432, + state: 'NY', + bill_number: 'S 4487', + status: 'Introduced', + legiscan_url: 'https://openstates.org/ny/bills/2025-2026/S4487/', +} as const; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index b49b317d2..4d9dba7eb 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -19,6 +19,9 @@ import { NET_COST_DRIFT_ROWS, NET_COST_ONLY_ROWS, TRACKER_NAMING_ROWS, + TWICE_FILED_BILL, + TWICE_FILED_EARLIER, + TWICE_FILED_LATER, } from '@/tests/fixtures/api/billFeedMocks'; import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; @@ -341,6 +344,26 @@ describe('tracker reads', () => { withStage: MANY_BILLS, }); }); + + test('given two status rows under one bill number then the later bill wins in any read order', async () => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const loads = []; + for (const processed of [ + [TWICE_FILED_EARLIER, TWICE_FILED_LATER], + [TWICE_FILED_LATER, TWICE_FILED_EARLIER], + ]) { + vi.stubGlobal( + 'fetch', + mockTrackerFetch({ research: [TWICE_FILED_BILL], processed_bills: processed }) + ); + const [bill] = (await fetchTrackerBills())!; + loads.push([bill.status, bill.legiscanUrl]); + } + + const later = [TWICE_FILED_LATER.status, TWICE_FILED_LATER.legiscan_url]; + expect(loads).toEqual([later, later]); + }); }); describe('fetchTrackerBills', () => { From 835e255f0156290aba16084bf22a032a942a1dfc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:53:16 -0400 Subject: [PATCH 3/4] Reforms: a bill held twice keeps the row with the later action When two status rows share one bill number (NY "S04487" from LegiScan and "S 4487" from OpenStates), the row with the later last_action_date now wins. On the same day, or with no dates, the row that links to LegiScan wins. The bill id is only a fixed tie-break after that: the OpenStates and Congress monitors hash their ids above 10,000,000, so a higher id does not mean a later bill, and the OpenStates row always won before. The test rows now carry realistic ids for each source. Co-Authored-By: Claude Opus 5.5 --- app/src/api/billFeed.ts | 82 ++++++++++++++------- app/src/tests/fixtures/api/billFeedMocks.ts | 44 +++++++++-- app/src/tests/unit/api/billFeed.test.ts | 46 +++++++----- 3 files changed, 122 insertions(+), 50 deletions(-) diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index b6dd0c460..02e52df6d 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -568,29 +568,56 @@ function normalizeBillNumber(value: string): string { const processedBillKey = (state: string, billNumber: string) => `${state.toUpperCase()}:${normalizeBillNumber(billNumber)}`; -/** A status row's LegiScan bill id as a number, lowest when it has none. */ -const billAge = (bill: any): number => { +/** A status row's last action day ("2026-03-04"), or "" when it has none. */ +const lastActionDay = (bill: any): string => { + const day = typeof bill.last_action_date === 'string' ? bill.last_action_date.slice(0, 10) : ''; + return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : ''; +}; + +/** + * Whether a status row came from LegiScan, read from its link: the + * OpenStates and Congress monitors store their own sites' links in the + * same column, and the row's source column defaults to "legiscan". + */ +const isLegiscanRow = (bill: any): boolean => + /^https?:\/\/(www\.)?legiscan\.com\//i.test(String(bill.legiscan_url ?? '')); + +/** A status row's bill id as a number, lowest when it has none. */ +const statusRowId = (bill: any): number => { const id = Number(bill.bill_id ?? NaN); return Number.isFinite(id) ? id : -Infinity; }; const statusRowText = (bill: any) => - JSON.stringify([bill.bill_id, bill.state, bill.bill_number, bill.status, bill.legiscan_url]); + JSON.stringify([ + bill.bill_id, + bill.state, + bill.bill_number, + bill.status, + bill.legiscan_url, + bill.last_action_date, + ]); + +const ascending = (a: number | string, b: number | string) => (a < b ? -1 : a > b ? 1 : 0); /** - * Sorts status rows so the later bill comes last. LegiScan numbers bills - * in the order it first sees them, so the higher bill id is the later - * bill; rows with the same id, or none, fall back to their contents. - * Two rows under one bill number (NY "S 4487" and "S04487") then resolve - * to the same row whatever order the tracker sent them in. + * Orders two status rows for one bill number, the preferred row last. + * The tracker can hold a bill twice under two spellings of its number: + * LegiScan's (NY "S04487") and OpenStates' ("S 4487"). The row with the + * later last action wins. On the same day, or with no dates, the LegiScan + * row wins: it names the stage more finely ("In Committee", where + * OpenStates says "Introduced") and links to LegiScan. The bill id and + * then the row's contents only fix the order of what is left. They do + * not date a bill: OpenStates and Congress ids are hashes, all above + * LegiScan's. The same row wins in any read order. */ -function byLaterBill(a: any, b: any): number { - const [ageA, ageB] = [billAge(a), billAge(b)]; - if (ageA !== ageB) { - return ageA < ageB ? -1 : 1; - } - const [textA, textB] = [statusRowText(a), statusRowText(b)]; - return textA < textB ? -1 : textA > textB ? 1 : 0; +function compareStatusRows(a: any, b: any): number { + return ( + ascending(lastActionDay(a), lastActionDay(b)) || + ascending(Number(isLegiscanRow(a)), Number(isLegiscanRow(b))) || + ascending(statusRowId(a), statusRowId(b)) || + ascending(statusRowText(a), statusRowText(b)) + ); } /** @@ -634,7 +661,11 @@ export async function fetchTrackerBills(): Promise { 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at', 'id' ), - trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url', 'bill_id'), + trackerSelect( + 'processed_bills', + 'bill_id,state,bill_number,status,legiscan_url,last_action_date', + 'bill_id' + ), trackerSelect( 'validation_metadata', 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against', @@ -651,19 +682,20 @@ export async function fetchTrackerBills(): Promise { const impactsById = new Map((impacts ?? []).map((impact) => [impact.id, impact])); const baselineById = new Map((baselines ?? []).map((row) => [row.id, row.baseline_params])); const validationById = new Map((validations ?? []).map((row) => [row.id, row])); - // The later bill last, so it is the row each map keeps for a shared key. - const processedRows = [...(processed ?? [])].sort(byLaterBill); const processedByBillId = new Map( - processedRows.flatMap((bill) => + (processed ?? []).flatMap((bill) => bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), bill]] ) ); - const processedByKey = new Map( - processedRows.map((bill) => [ - processedBillKey(String(bill.state), String(bill.bill_number)), - bill, - ]) - ); + // Two rows can share a bill number's key: the preferred one holds it. + const processedByKey = new Map(); + for (const bill of processed ?? []) { + const key = processedBillKey(String(bill.state), String(bill.bill_number)); + const held = processedByKey.get(key); + if (!held || compareStatusRows(held, bill) < 0) { + processedByKey.set(key, bill); + } + } return research .filter((record) => isBillRecord(record)) diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 5247f83a6..318ae2029 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -151,25 +151,59 @@ export const MANY_BILL_ROWS: TrackerTableRows = { })), }; -/** NY S 4487, filed twice in the status table under two spellings of its number. */ +/** NY S 4487, held twice in the status table under two spellings of its number. */ export const TWICE_FILED_BILL = { id: 'ny-s-4487', state: 'NY', type: 'bill', title: 'NY S 4487', } as const; -/** The later of the two rows: LegiScan gives a later bill a higher id. */ -export const TWICE_FILED_LATER = { +/** LegiScan's row: a LegiScan bill id (about 2 million) and its finer stage. */ +export const TWICE_FILED_LEGISCAN = { bill_id: 1_987_654, state: 'NY', bill_number: 'S04487', status: 'In Committee', legiscan_url: 'https://legiscan.com/NY/bill/S04487/2025', } as const; -export const TWICE_FILED_EARLIER = { - bill_id: 1_765_432, +/** OpenStates' row: the monitor hashes its bill id to 10,000,000 or more. */ +export const TWICE_FILED_OPENSTATES = { + bill_id: 47_123_456, state: 'NY', bill_number: 'S 4487', status: 'Introduced', legiscan_url: 'https://openstates.org/ny/bills/2025-2026/S4487/', } as const; + +/** The two rows' last action dates, and the row that keeps the bill number. */ +export const TWICE_FILED_CASES = [ + { + when: 'the LegiScan row acted last', + winnerName: 'LegiScan', + legiscanDate: '2025-05-02', + openstatesDate: '2025-03-10', + winner: TWICE_FILED_LEGISCAN, + }, + { + when: 'the OpenStates row acted last', + winnerName: 'OpenStates', + legiscanDate: '2025-03-10', + openstatesDate: '2025-05-02', + winner: TWICE_FILED_OPENSTATES, + }, + { + // A timestamp still reads as its day. + when: 'both acted the same day', + winnerName: 'LegiScan', + legiscanDate: '2025-03-10', + openstatesDate: '2025-03-10T14:30:00+00:00', + winner: TWICE_FILED_LEGISCAN, + }, + { + when: 'neither has a date', + winnerName: 'LegiScan', + legiscanDate: null, + openstatesDate: null, + winner: TWICE_FILED_LEGISCAN, + }, +]; diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 4d9dba7eb..725b354bb 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -20,8 +20,9 @@ import { NET_COST_ONLY_ROWS, TRACKER_NAMING_ROWS, TWICE_FILED_BILL, - TWICE_FILED_EARLIER, - TWICE_FILED_LATER, + TWICE_FILED_CASES, + TWICE_FILED_LEGISCAN, + TWICE_FILED_OPENSTATES, } from '@/tests/fixtures/api/billFeedMocks'; import { TRACKER_HOME } from '@/tests/fixtures/libs/flagship/billTrackerMocks'; @@ -345,25 +346,30 @@ describe('tracker reads', () => { }); }); - test('given two status rows under one bill number then the later bill wins in any read order', async () => { - vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); - vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); - const loads = []; - for (const processed of [ - [TWICE_FILED_EARLIER, TWICE_FILED_LATER], - [TWICE_FILED_LATER, TWICE_FILED_EARLIER], - ]) { - vi.stubGlobal( - 'fetch', - mockTrackerFetch({ research: [TWICE_FILED_BILL], processed_bills: processed }) - ); - const [bill] = (await fetchTrackerBills())!; - loads.push([bill.status, bill.legiscanUrl]); + test.each(TWICE_FILED_CASES)( + 'given two status rows under one bill number when $when then the $winnerName row wins in any read order', + async ({ legiscanDate, openstatesDate, winner }) => { + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); + vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); + const legiscan = { ...TWICE_FILED_LEGISCAN, last_action_date: legiscanDate }; + const openstates = { ...TWICE_FILED_OPENSTATES, last_action_date: openstatesDate }; + const loads = []; + for (const processed of [ + [legiscan, openstates], + [openstates, legiscan], + ]) { + vi.stubGlobal( + 'fetch', + mockTrackerFetch({ research: [TWICE_FILED_BILL], processed_bills: processed }) + ); + const [bill] = (await fetchTrackerBills())!; + loads.push([bill.status, bill.legiscanUrl]); + } + + const won = [winner.status, winner.legiscan_url]; + expect(loads).toEqual([won, won]); } - - const later = [TWICE_FILED_LATER.status, TWICE_FILED_LATER.legiscan_url]; - expect(loads).toEqual([later, later]); - }); + ); }); describe('fetchTrackerBills', () => { From 33a276865d6666454d5c0496a0765046d6b775d0 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 15 Oct 2026 08:53:51 -0400 Subject: [PATCH 4/4] Reforms: the paged feed test checks each bill's baseline read The 1,001-bill test now gives every reform_impacts row its baseline params and checks that every bill's provisions carry that baseline. Before, the baselines read could lose its row order and the test still passed; now that read drops the 1,001st bill's baseline and the test fails. Co-Authored-By: Claude Opus 5.5 --- app/src/tests/fixtures/api/billFeedMocks.ts | 6 ++++++ app/src/tests/unit/api/billFeed.test.ts | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/tests/fixtures/api/billFeedMocks.ts b/app/src/tests/fixtures/api/billFeedMocks.ts index 318ae2029..ec2fbf2fb 100644 --- a/app/src/tests/fixtures/api/billFeedMocks.ts +++ b/app/src/tests/fixtures/api/billFeedMocks.ts @@ -121,6 +121,9 @@ export const LATE_STATUS_ROWS: TrackerTableRows = { /** Bills enough that every tracker table runs past one page. */ export const MANY_BILLS = TRACKER_PAGE_LIMIT + 1; export const MANY_BILLS_STATUS = 'In Committee'; +/** The rate each of those bills changes, and the law it was compared with. */ +const MANY_BILLS_RATE_PATH = 'gov.states.ok.tax.income.rate'; +export const MANY_BILLS_BASELINE_RATE = 0.0475; const MANY_BILL_NUMBERS = Array.from({ length: MANY_BILLS }, (_, index) => 1000 + index); @@ -132,10 +135,13 @@ export const MANY_BILL_ROWS: TrackerTableRows = { type: 'bill', title: `OK HB ${number}`, })), + // One row serves both reform_impacts reads: the run and its baseline. reform_impacts: MANY_BILL_NUMBERS.map((number) => ({ id: `ok-hb${number}`, computed: true, budgetary_impact: { stateRevenueImpact: -number * 1000 }, + reform_params: { [MANY_BILLS_RATE_PATH]: 0.04 }, + baseline_params: { [MANY_BILLS_RATE_PATH]: MANY_BILLS_BASELINE_RATE }, })), validation_metadata: MANY_BILL_NUMBERS.map((number) => ({ id: `ok-hb${number}`, diff --git a/app/src/tests/unit/api/billFeed.test.ts b/app/src/tests/unit/api/billFeed.test.ts index 725b354bb..c133571ef 100644 --- a/app/src/tests/unit/api/billFeed.test.ts +++ b/app/src/tests/unit/api/billFeed.test.ts @@ -13,6 +13,7 @@ import { LATE_STATUS_ROWS, MANY_BILL_ROWS, MANY_BILLS, + MANY_BILLS_BASELINE_RATE, MANY_BILLS_STATUS, mockPagedTrackerFetch, mockTrackerFetch, @@ -322,7 +323,7 @@ describe('tracker reads', () => { expect([bill.status, bill.legiscanUrl]).toEqual([LATE_STATUS.status, LATE_STATUS.legiscanUrl]); }); - test('given every table past one page then each bill reads once, with its run, check and stage', async () => { + test('given every table past one page then each bill reads once, with its run, baseline, check and stage', async () => { vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_URL', 'https://tracker.example.supabase.co'); vi.stubEnv('NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY', 'anon-key'); vi.stubGlobal('fetch', mockPagedTrackerFetch(MANY_BILL_ROWS)); @@ -335,12 +336,16 @@ describe('tracker reads', () => { listed: bills.length, unique: billsWith(() => true), withRun: billsWith((bill) => bill.impacts?.revenue !== undefined), + withBaseline: billsWith( + (bill) => bill.provisions[0]?.baselineIntervals?.[0]?.value === MANY_BILLS_BASELINE_RATE + ), withCheck: billsWith((bill) => bill.validation?.withinRange === true), withStage: billsWith((bill) => bill.status === MANY_BILLS_STATUS), }).toEqual({ listed: MANY_BILLS, unique: MANY_BILLS, withRun: MANY_BILLS, + withBaseline: MANY_BILLS, withCheck: MANY_BILLS, withStage: MANY_BILLS, });