diff --git a/resources/js/components/landing/hero.tsx b/resources/js/components/landing/hero.tsx
index fd5a2ef..abfcd44 100644
--- a/resources/js/components/landing/hero.tsx
+++ b/resources/js/components/landing/hero.tsx
@@ -7,6 +7,7 @@ import Button from '@/components/ui/button';
import { AppleGlyph } from '@/components/ui/glyph';
import { trackDownload } from '@/lib/analytics';
import { GITHUB_REPO_URL } from '@/data/links';
+import { ENGINE_COUNT } from '@/data/engines';
interface Props {
githubStars?: number | null;
@@ -89,7 +90,7 @@ export default function Hero({ githubStars, latestRelease }: Props) {
* sections below read as evidence for a claim already made.
*/}
- TablePro is a free, open source database client for macOS. 25 engines through native drivers, and
+ TablePro is a free, open source database client for macOS. {ENGINE_COUNT} engines through native drivers, and
nothing runs against your database that you have not read first.
diff --git a/resources/js/components/landing/spec-strip.tsx b/resources/js/components/landing/spec-strip.tsx
index d7b2274..be6af80 100644
--- a/resources/js/components/landing/spec-strip.tsx
+++ b/resources/js/components/landing/spec-strip.tsx
@@ -4,6 +4,7 @@ import DataTable, { TABLE_COLUMN_RULE, TABLE_ROW_RULE } from '@/components/ui/da
import FootNote from '@/components/ui/footnote';
import { FullLine } from '@/components/ui/full-line';
import { GITHUB_REPO_URL } from '@/data/links';
+import { BUNDLED_ENGINE_COUNT, ENGINE_COUNT, ON_DEMAND_ENGINE_COUNT } from '@/data/engines';
interface Props {
latestRelease?: { version: string | null; publishedAt: string | null; countLast30Days: number | null } | null;
@@ -55,7 +56,7 @@ function formatReleaseDate(iso: string): string {
*/
export default function SpecStrip({ latestRelease, downloads }: Props) {
const specs: Spec[] = [
- { label: 'databases', value: '25', sub: '9 bundled, 16 on demand', numeric: true },
+ { label: 'databases', value: String(ENGINE_COUNT), sub: `${BUNDLED_ENGINE_COUNT} bundled, ${ON_DEMAND_ENGINE_COUNT} on demand`, numeric: true },
{ label: 'cold_start', value: 'Under 1s', sub: 'Cold, to first window', numeric: true },
{ label: 'idle_rss', value: '~80 MB', sub: 'One connection, open and idle', numeric: true },
downloads?.total
diff --git a/resources/js/data/engines.ts b/resources/js/data/engines.ts
new file mode 100644
index 0000000..14bb5c3
--- /dev/null
+++ b/resources/js/data/engines.ts
@@ -0,0 +1,56 @@
+import gridData from '../../data/database-grid.json';
+
+/**
+ * Every engine-count claim on the site, derived from one dataset.
+ *
+ * The figure was written out as "25" in eight places — the hero, the grid
+ * headline and its lede, the spec strip, the licence block, an FAQ answer, the
+ * JSON-LD publisher blurb and two fields on Home.tsx — plus roughly thirty more
+ * across `comparisons.json`. Three of those were stale in a way nobody could
+ * see from the source: the app had reached 28 registered types while the site
+ * still said 25 and the docs said 27.
+ *
+ * The trap in the old copy was that "25" was not arbitrary. It counted distinct
+ * `driverGroup` values in `database-grid.json`, which is a *driver* count, while
+ * the headline above it promised *databases*. Two different quantities wearing
+ * the same number, so raising one silently broke the other.
+ *
+ * These derive from the grid instead. The grid is what a reader can actually
+ * count on the page, so a claim that disagrees with it is a claim the page
+ * disproves two rows down. `EngineCountTest` pins the derived value, so adding
+ * a tile is a deliberate change to the marketing copy rather than a side effect.
+ */
+interface DatabaseTile {
+ distribution: 'builtin' | 'plugin';
+}
+
+const tiles = gridData as DatabaseTile[];
+
+/**
+ * Engines a connection can be opened against — the app's registered type ids,
+ * one grid tile each.
+ *
+ * Turso was the open question and is now counted. It had its own `DatabaseType`
+ * and its own `/turso-client` page but no snapshot, resolving through
+ * `reverseTypeIndex["Turso"] = "libSQL"`, so it shared a `libSQL / Turso` tile
+ * and the app returned 28. It now follows the ScyllaDB precedent — an alias that
+ * also carries an entry of its own — so libSQL and Turso are two tiles over one
+ * driver, exactly as Cassandra and ScyllaDB are.
+ */
+export const ENGINE_COUNT: number = tiles.length;
+
+/** Drivers compiled into the app, ready on first launch. */
+export const BUNDLED_ENGINE_COUNT: number = tiles.filter((tile) => tile.distribution === 'builtin').length;
+
+/** The rest, fetched and signature-verified the first time one is picked. */
+export const ON_DEMAND_ENGINE_COUNT: number = ENGINE_COUNT - BUNDLED_ENGINE_COUNT;
+
+/**
+ * Driver plugin targets in the app repository, which is fewer than the engines
+ * they serve: PostgreSQL also drives Redshift, CockroachDB and PGlite, Cassandra
+ * drives ScyllaDB, and libSQL drives Turso.
+ *
+ * Nothing in this repository can derive this — it is a count of build targets in
+ * an application this site cannot see — so it is a literal, and the only one.
+ */
+export const DRIVER_PLUGIN_COUNT = 23;
diff --git a/resources/js/data/faqs.ts b/resources/js/data/faqs.ts
index 1ec311e..80434c2 100644
--- a/resources/js/data/faqs.ts
+++ b/resources/js/data/faqs.ts
@@ -1,3 +1,5 @@
+import { DRIVER_PLUGIN_COUNT, ENGINE_COUNT } from '@/data/engines';
+
export interface FaqItem {
question: string;
answer: string;
@@ -23,7 +25,7 @@ export interface FaqItem {
export const faqs: FaqItem[] = [
{
question: 'Is it really free, or free for now?',
- answer: 'Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All 25 databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. Starter adds seven: Compare & Sync, Query Insights, result charts, iCloud Sync, Linked Folders, encrypted connection export, and environment variables in connection fields. Team adds two more, the Team Catalog and the Team Library.',
+ answer: `Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All ${ENGINE_COUNT} databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. Starter adds seven: Compare & Sync, Query Insights, result charts, iCloud Sync, Linked Folders, encrypted connection export, and environment variables in connection fields. Team adds two more, the Team Catalog and the Team Library.`,
},
{
question: 'Can I use TablePro at work under AGPLv3?',
@@ -42,8 +44,14 @@ export const faqs: FaqItem[] = [
answer: 'In the macOS Keychain. Connection details live in a plain JSON file with no secrets in it. A connection can also skip storage entirely and resolve its password at connect time from a file, an environment variable, a shell command, 1Password, HashiCorp Vault or AWS Secrets Manager.',
},
{
- question: 'Why do Cassandra and ScyllaDB appear separately when you say 25?',
- answer: 'The grid shows 26 tiles because Cassandra and ScyllaDB are two entries in the connection chooser. They share one driver, which is why the driver count is 25.',
+ /*
+ * This used to reconcile 26 tiles against a claim of 25, because the
+ * headline quoted driver groups while the grid rendered entries. The
+ * grid now renders one tile per engine, so that gap is gone and the
+ * only one left is the honest one: a driver can serve several engines.
+ */
+ question: `Why do Cassandra and ScyllaDB appear separately when you say ${ENGINE_COUNT}?`,
+ answer: `Because they are two entries in the connection chooser, and the grid shows one tile per entry. The ${ENGINE_COUNT} engines run on ${DRIVER_PLUGIN_COUNT} native driver plugins, because one driver can serve several engines: PostgreSQL also drives Redshift, CockroachDB and PGlite, Cassandra drives ScyllaDB, and libSQL drives Turso.`,
},
{
question: 'Is this abandoned like Sequel Pro?',
diff --git a/resources/js/lib/structured-data.ts b/resources/js/lib/structured-data.ts
index c246515..b8e42c0 100644
--- a/resources/js/lib/structured-data.ts
+++ b/resources/js/lib/structured-data.ts
@@ -1,4 +1,5 @@
import { GITHUB_REPO_URL } from '@/data/links';
+import { ENGINE_COUNT } from '@/data/engines';
export interface BreadcrumbCrumb {
name: string;
@@ -42,7 +43,7 @@ export function buildOrganizationJsonLd(baseUrl: string): object {
height: 256,
},
description:
- 'TablePro builds a native database client for macOS, iPadOS and iOS covering 25 engines, released as open source under AGPLv3.',
+ `TablePro builds a native database client for macOS, iPadOS and iOS covering ${ENGINE_COUNT} engines, released as open source under AGPLv3.`,
// All five profiles the footer links, not two of them.
sameAs: [
GITHUB_REPO_URL,
diff --git a/resources/js/pages/Home.tsx b/resources/js/pages/Home.tsx
index 99d2295..44d5cd5 100644
--- a/resources/js/pages/Home.tsx
+++ b/resources/js/pages/Home.tsx
@@ -15,6 +15,7 @@ import SEOHead from '@/components/seo/seo-head';
import { buildOrganizationJsonLd } from '@/lib/structured-data';
import { GITHUB_REPO_URL } from '@/data/links';
import { STARTER_PRICE, TEAM_SEAT_PRICE } from '@/data/pricing';
+import { ENGINE_COUNT } from '@/data/engines';
interface LatestRelease {
version: string | null;
@@ -61,10 +62,10 @@ const HOME_TITLE = 'TablePro - Free, Open Source Database Client for Mac';
* answer engine lifts for "what is TablePro".
*/
const HOME_DESCRIPTION =
- 'TablePro is a free, open source database client for macOS. 25 engines through native drivers, in a 20 MB Swift app that opens in under a second.';
+ `TablePro is a free, open source database client for macOS. ${ENGINE_COUNT} engines through native drivers, in a 20 MB Swift app that opens in under a second.`;
const FEATURE_LIST = [
- '25 databases through native drivers',
+ `${ENGINE_COUNT} databases through native drivers`,
'SQL editor with tree-sitter highlighting and Vim mode',
'Editable data grid with deferred commit',
'13 AI providers, bring your own key',
@@ -121,7 +122,7 @@ function buildAppJsonLd(
softwareRequirements: 'macOS 14.0 or later, Apple Silicon or Intel',
description: HOME_DESCRIPTION,
disambiguatingDescription:
- 'TablePro is a native macOS GUI client for 25 database engines, written in Swift rather than Electron or Java, and released as open source under AGPLv3.',
+ `TablePro is a native macOS GUI client for ${ENGINE_COUNT} database engines, written in Swift rather than Electron or Java, and released as open source under AGPLv3.`,
// Every URL from canonicalBaseUrl. Four of these used to hardcode the
// production origin while `screenshot` did not, so one object carried
// two origins on any non-production host.
diff --git a/tests/Feature/Landing/DatabaseGridDataTest.php b/tests/Feature/Landing/DatabaseGridDataTest.php
index 69c90bb..84066e3 100644
--- a/tests/Feature/Landing/DatabaseGridDataTest.php
+++ b/tests/Feature/Landing/DatabaseGridDataTest.php
@@ -8,9 +8,14 @@
});
/**
- * The homepage grid advertises "25 databases" while rendering 26 tiles, because
- * Cassandra and ScyllaDB share a driver. These assertions are what keep that
- * claim honest as engines are added.
+ * The grid is the source every engine-count claim on the site derives from, so
+ * these assertions are what keep the headline honest as engines are added.
+ *
+ * It used to advertise "25 databases" above 26 tiles: the copy counted driver
+ * groups and the grid rendered entries. Dameng and Kafka then shipped in the app
+ * without ever getting a tile, so the site under-sold two engines while quoting a
+ * number that matched neither, and libSQL and Turso shared a single tile while
+ * the app registered them separately. One tile per engine now, counted here.
*/
function gridTiles(): array
{
@@ -22,17 +27,24 @@ function databaseEntries(): array
return json_decode(file_get_contents(base_path('resources/data/databases.json')), true);
}
-it('ships 26 tiles covering 25 distinct drivers', function (): void {
+it('ships 29 tiles covering 27 distinct drivers', function (): void {
$tiles = gridTiles();
- expect($tiles)->toHaveCount(26);
- expect(array_unique(array_column($tiles, 'driverGroup')))->toHaveCount(25);
+ expect($tiles)->toHaveCount(29);
+
+ /*
+ * Two fewer groups than tiles, and both pairs are deliberate: Cassandra with
+ * ScyllaDB, and libSQL with Turso. Each pair is two engines a user picks
+ * separately over one driver, which is why the engine count and
+ * `DRIVER_PLUGIN_COUNT` are different numbers rather than a discrepancy.
+ */
+ expect(array_unique(array_column($tiles, 'driverGroup')))->toHaveCount(27);
});
-it('splits distribution as 9 bundled and 17 plugin tiles', function (): void {
+it('splits distribution as 9 bundled and 20 plugin tiles', function (): void {
$distribution = array_count_values(array_column(gridTiles(), 'distribution'));
- expect($distribution)->toBe(['builtin' => 9, 'plugin' => 17]);
+ expect($distribution)->toBe(['builtin' => 9, 'plugin' => 20]);
});
it('gives every tile a complete shape', function (): void {
@@ -49,42 +61,91 @@ function databaseEntries(): array
it('points every tile icon at a file that exists', function (): void {
foreach (gridTiles() as $tile) {
+ if ($tile['icon'] === null) {
+ continue;
+ }
+
expect($tile['icon'])->toStartWith('/images/databases/');
expect(public_path($tile['icon']))
->toBeReadableFile("Missing icon for {$tile['name']}: {$tile['icon']}");
}
});
-it('assigns every tile to a known category and accounts for all 26', function (): void {
+/**
+ * `DatabaseMark` renders a two-letter monogram when a tile has no icon, which is
+ * what lets an engine reach the grid before its artwork does. That fallback is a
+ * deliberate exception per engine, not a way to skip the icon: naming the tiles
+ * here means a third one cannot appear without someone deciding it should.
+ */
+it('lets only the engines still awaiting artwork fall back to a monogram', function (): void {
+ $iconless = array_column(
+ array_filter(gridTiles(), static fn(array $tile): bool => $tile['icon'] === null),
+ 'name',
+ );
+
+ expect($iconless)->toBe(['Dameng', 'Kafka', 'libSQL']);
+});
+
+it('assigns every tile to a known category and accounts for all 29', function (): void {
$counts = array_count_values(array_column(gridTiles(), 'category'));
expect($counts)->toBe([
- 'relational' => 8,
- 'file-embedded' => 4,
- 'document-kv' => 7,
+ 'relational' => 9,
+ 'file-embedded' => 5,
+ 'document-kv' => 8,
'analytics' => 3,
'cloud-api' => 4,
]);
- expect(array_sum($counts))->toBe(26);
+ expect(array_sum($counts))->toBe(29);
});
it('keeps every tile link resolvable and consistent with its marketing page', function (): void {
$entries = collect(databaseEntries())->keyBy(fn(array $entry): string => '/' . $entry['slug']);
foreach (gridTiles() as $tile) {
+ if ($tile['href'] === null) {
+ continue;
+ }
+
expect($tile['href'])->toBeString()->toStartWith('/');
$entry = $entries->get($tile['href']);
expect($entry)->not->toBeNull("No databases.json entry for grid href {$tile['href']}");
- expect($entry['name'])->toBe($tile['name'], "Name drift on {$tile['href']}");
- expect($entry['icon'])->toBe($tile['icon'], "Icon drift on {$tile['href']}");
+
+ /*
+ * Containment, not equality: libSQL and Turso are two tiles over one
+ * page, which is still titled "libSQL / Turso" because it documents both.
+ * A tile whose name the page never mentions is still drift.
+ */
+ expect($entry['name'])->toContain($tile['name']);
+
+ if ($tile['icon'] !== null) {
+ expect($entry['icon'])->toBe($tile['icon'], "Icon drift on {$tile['href']}");
+ }
}
});
it('serves a page for every tile link', function (): void {
foreach (gridTiles() as $tile) {
+ if ($tile['href'] === null) {
+ continue;
+ }
+
test()->get('http://' . config('app.web_domain') . $tile['href'])
->assertOk();
}
});
+
+/**
+ * A tile with no href has no marketing page yet. That is allowed, but it is the
+ * kind of gap that quietly becomes permanent, so the exceptions are named.
+ */
+it('lets only the engines still awaiting a page go unlinked', function (): void {
+ $unlinked = array_column(
+ array_filter(gridTiles(), static fn(array $tile): bool => $tile['href'] === null),
+ 'name',
+ );
+
+ expect($unlinked)->toBe(['Dameng', 'Kafka']);
+});
diff --git a/tests/Feature/Landing/EngineCountTest.php b/tests/Feature/Landing/EngineCountTest.php
new file mode 100644
index 0000000..4b444d1
--- /dev/null
+++ b/tests/Feature/Landing/EngineCountTest.php
@@ -0,0 +1,147 @@
+toBeReadableFile("Missing source: {$relative}");
+
+ return (string) file_get_contents($path);
+};
+
+/**
+ * Sources whose rendered copy quotes the engine count.
+ *
+ * @var list
+ */
+$claimSources = [
+ 'resources/js/data/faqs.ts',
+ 'resources/js/pages/Home.tsx',
+ 'resources/js/lib/structured-data.ts',
+ 'resources/js/components/landing/hero.tsx',
+ 'resources/js/components/landing/database-grid.tsx',
+ 'resources/js/components/landing/spec-strip.tsx',
+ 'resources/js/components/landing/license.tsx',
+];
+
+/**
+ * Comments are history, not copy. `license.tsx` still quotes the retired
+ * "25 databases, SQL editor, data grid…" row it replaced, and `database-grid`
+ * explains itself in terms of "twenty six tiles". Both are accurate records of
+ * what the file used to say and neither reaches a reader, so the scan reads what
+ * ships rather than what is remembered.
+ */
+function renderedCopy(string $contents): string
+{
+ return (string) preg_replace(
+ ['#/\*.*?\*/#s', '#//[^\n]*#'],
+ '',
+ $contents,
+ );
+}
+
+it('pins the engine count every marketing claim derives from', function (): void {
+ /*
+ * 29 is the number of keys in the app's `allRegisteredTypeIds()`: 7 curated,
+ * 15 registry, 3 cloud, one each for Elasticsearch, Kafka and SurrealDB, and
+ * a snapshot of its own for Turso.
+ *
+ * Turso was the alias that made this ambiguous — it had a `DatabaseType` and
+ * a `/turso-client` page but resolved to libSQL, so the app counted 28 while
+ * the site showed one `libSQL / Turso` tile. It now carries an entry of its
+ * own on the ScyllaDB precedent, so libSQL and Turso are two tiles sharing
+ * one driver and the two repositories agree on 29.
+ */
+ $tiles = json_decode(file_get_contents(base_path('resources/data/database-grid.json')), true);
+
+ expect($tiles)->toHaveCount(29);
+});
+
+it('states the engine count in one module and never as a literal', function () use ($claimSources, $readSource): void {
+ /*
+ * Any digit standing next to "databases", "engines" or "drivers" in rendered
+ * copy is a second copy of the figure, which is how the site came to publish
+ * a number the app had not used in three releases. The count is interpolated
+ * from `engines.ts` or it is not stated.
+ */
+ $offenders = [];
+
+ foreach ($claimSources as $source) {
+ $copy = renderedCopy($readSource($source));
+
+ if (preg_match_all('/\b\d+\s+(?:database engines?|driver plugins?|databases?|engines?|drivers?)\b/i', $copy, $matches) === 0) {
+ continue;
+ }
+
+ foreach ($matches[0] as $match) {
+ $offenders[] = "{$source}: \"{$match}\"";
+ }
+ }
+
+ expect($offenders)->toBe([], "Hardcoded engine counts, import from @/data/engines instead:\n " . implode("\n ", $offenders));
+});
+
+it('derives the count and its split from the grid rather than restating them', function () use ($readSource): void {
+ $engines = $readSource('resources/js/data/engines.ts');
+
+ expect($engines)
+ ->toContain("import gridData from '../../data/database-grid.json'")
+ ->toContain('export const ENGINE_COUNT: number = tiles.length;');
+
+ /*
+ * The lede reads "9 drivers ship inside the app. The other 20 download…".
+ * Spelling either half out is how it last drifted: the grid gained a tile,
+ * "nine and sixteen" stayed, and the two halves stopped summing to the
+ * headline directly above them.
+ */
+ expect($engines)->toContain('export const ON_DEMAND_ENGINE_COUNT: number = ENGINE_COUNT - BUNDLED_ENGINE_COUNT;');
+});
+
+it('quotes one engine count across every comparison page', function (): void {
+ /*
+ * `comparisons.json` cannot import the constant, so its literals are pinned
+ * against it here — every "Databases" row, and the OG card figures, which are
+ * generated from their own fields and so drift silently from the table they
+ * advertise.
+ */
+ $entries = json_decode(file_get_contents(base_path('resources/data/comparisons.json')), true);
+
+ $offenders = [];
+
+ foreach ($entries as $entry) {
+ foreach ($entry['rows'] ?? [] as $row) {
+ if (($row['label'] ?? null) !== 'Databases') {
+ continue;
+ }
+
+ if (! str_starts_with((string) $row['tablePro'], '29')) {
+ $offenders[] = "{$entry['slug']}: Databases row says \"{$row['tablePro']}\"";
+ }
+ }
+
+ /*
+ * Only the cards whose stat *is* the engine count. Most quote RAM or a
+ * price instead, and "$0 PER DEVICE" is not a number that moves with the
+ * driver list.
+ */
+ if (($entry['ogStatLabel'] ?? null) === 'DATABASES' && ($entry['ogStatValue'] ?? null) !== '29') {
+ $offenders[] = "{$entry['slug']}: ogStatValue says \"{$entry['ogStatValue']}\"";
+ }
+ }
+
+ expect($offenders)->toBe([], "Comparison pages disagreeing with ENGINE_COUNT:\n " . implode("\n ", $offenders));
+});
diff --git a/tests/Feature/Landing/HomepageRenderTest.php b/tests/Feature/Landing/HomepageRenderTest.php
index f7fa4d8..a5c71ec 100644
--- a/tests/Feature/Landing/HomepageRenderTest.php
+++ b/tests/Feature/Landing/HomepageRenderTest.php
@@ -67,14 +67,33 @@ function ssrHomepageHtml(): string
}
});
-it('server-renders all 26 database tiles', function (): void {
+it('server-renders all 29 database tiles', function (): void {
$html = ssrHomepageHtml();
- expect(substr_count($html, '/images/databases/'))->toBe(26);
+ /*
+ * Counted on the tile, not on the icon. Dameng and Kafka render through
+ * `DatabaseMark`'s monogram fallback because they have no artwork yet, so
+ * counting `/images/databases/` returns 26 whether the grid holds 26 tiles
+ * or 29 — an assertion that would have kept passing, under this name, while
+ * three engines were missing from the page.
+ */
+ $tiles = json_decode(file_get_contents(base_path('resources/data/database-grid.json')), true);
- foreach (['PGlite', 'SurrealDB', 'Teradata', 'Trino', 'Beancount', 'libSQL / Turso', 'etcd'] as $name) {
- expect($html)->toContain($name);
- }
+ expect($tiles)->toHaveCount(29);
+
+ /*
+ * Collected, not asserted in the loop. `toContain()` is `(mixed ...$needles)`
+ * with no message parameter, so a message passed there becomes a second
+ * needle and the failure reports the message as the missing string.
+ */
+ $missing = array_values(array_filter(
+ array_column($tiles, 'name'),
+ static fn(string $name): bool => ! str_contains($html, $name),
+ ));
+
+ expect($missing)->toBe([], 'Grid tiles missing from SSR output: ' . implode(', ', $missing));
+
+ expect(substr_count($html, '/images/databases/'))->toBe(26);
});
it('server-renders the verified claims and none of the retired ones', function (): void {
@@ -84,7 +103,7 @@ function ssrHomepageHtml(): string
->toContain('Every database.')
// "client", not "app": the head noun of every query this page targets.
->toContain('One native Mac client.')
- ->toContain('25 databases.')
+ ->toContain('29 databases.')
->toContain('Starter');
foreach (['15+ databases', '21+ supported', 'iOS 17+', 'free forever on one Mac', '9 built-in themes'] as $stale) {
diff --git a/tests/Feature/Seo/StaleClaimsTest.php b/tests/Feature/Seo/StaleClaimsTest.php
index 20cc8fd..97ffd0b 100644
--- a/tests/Feature/Seo/StaleClaimsTest.php
+++ b/tests/Feature/Seo/StaleClaimsTest.php
@@ -21,9 +21,9 @@
$banned = [
// Not bare '18+': that also matches 'macOS 14+, iOS 18+', which is the
// correct deployment target. The stale claim was always '18+ databases'.
- '18+ databases' => 'the database count is 25',
- '15+ databases' => 'the database count is 25',
- '21+' => 'the database count is 25',
+ '18+ databases' => 'the database count is 29',
+ '15+ databases' => 'the database count is 29',
+ '21+' => 'the database count is 29',
'9 built-in themes' => 'there are 4 built-in themes',
'iOS 17' => 'the iOS deployment target is 18.0',
'free forever on one Mac' => 'the unlicensed app has no per-Mac limit',
@@ -118,8 +118,13 @@
* already answered in prose elsewhere on the homepage, and `faqs.ts` spread
* `home-faqs.ts` in wholesale, so /faq answered each of them a third time.
* The questions all survived the move; only the duplicate render sites went.
+ *
+ * Counted on the key, not on the quote character. This matched `question: '`
+ * until one question began interpolating the engine count and switched to a
+ * template literal — at which point a file that still held all fourteen
+ * questions reported thirteen.
*/
- expect(substr_count($readSource('resources/js/data/faqs.ts'), "question: '"))->toBe(14);
+ expect(preg_match_all("/\bquestion: ['`]/", $readSource('resources/js/data/faqs.ts')))->toBe(14);
expect(file_exists(base_path('resources/js/data/home-faqs.ts')))
->toBeFalse('home-faqs.ts is gone; /faq is the only place a question is asked');
@@ -220,7 +225,7 @@
expect($routed)->toBe($authored);
});
-it('never implies a database count other than 25', function (): void {
+it('never implies a database count other than 29', function (): void {
/*
* Four entries carried counts from the era when TablePro shipped 18 —
* Postico promised "Postgres plus 17 other databases" and named five
@@ -229,7 +234,8 @@
*
* Relative phrasing is what rots: "plus N more" has to be re-derived every
* time the driver count moves, and nobody remembers to. This catches the
- * ones that no longer add up.
+ * ones that no longer add up — it caught all four when the count went to 28,
+ * and all four again at 29.
*/
$entries = json_decode(file_get_contents(base_path('resources/data/comparisons.json')), true);
@@ -249,8 +255,8 @@
/*
* Only the engines named in the same sentence count. Postico's
* verdict reads "excellent for Postgres alone. TablePro covers the
- * same ground for Postgres plus 24 other databases" — counting the
- * whole string sees Postgres twice and makes 25 look like 26.
+ * same ground for Postgres plus 28 other databases" — counting the
+ * whole string sees Postgres twice and makes 29 look like 30.
*/
$before = substr($value, 0, strpos($value, $m[0]));
$sentence = preg_split('/(?<=[.!?])\s+/', $before);
@@ -260,13 +266,13 @@
(string) end($sentence),
);
- if ($named + (int) $m[1] !== 25) {
+ if ($named + (int) $m[1] !== 29) {
$offenders[] = "{$entry['slug']}: \"{$m[0]}\" after {$named} named engines = " . ($named + (int) $m[1]);
}
});
}
- expect($offenders)->toBe([], "Database counts that do not total 25:\n " . implode("\n ", $offenders));
+ expect($offenders)->toBe([], "Database counts that do not total 29:\n " . implode("\n ", $offenders));
});
it('quotes one price per competitor claim, consistently within an entry', function (): void {