removed fp6 from shop
All checks were successful
Deploy / build-deploy (push) Successful in 5m35s

This commit is contained in:
Jorijn van der Graaf 2026-09-05 18:38:39 +02:00
commit 25805a093a
20 changed files with 788 additions and 805 deletions

View file

@ -26,263 +26,136 @@ void Check(bool ok, std::string_view what, std::string_view got = {}) {
got.empty() ? "" : " got: ", got);
}
// ── a priced product, built by hand ───────────────────────────────────
// Since 2026-09-05 the catalogue lists the donation item alone: the goods that
// opened the shop were withdrawn. The renderer's contract for priced goods —
// the ProductGroup record, the merchant fields, the carrier-table filter, the
// spec lede, the preview blob — must not lapse while nothing exercises it, so
// this fixture stands in for the next listing. It carries every optional field
// a real one would: three colours at distinct prices (so from-price and default
// selection are decidable), a boxed weight that lands in the fixture ladders'
// cheap band, a brand for the spec lede, a photo path and a warranty text. The
// stickers are supplier + €60.50, the same rule a real listing follows.
Product Fixture() {
Product pr;
pr.slug = "fixture-handheld";
pr.name = "Fixture Handheld";
pr.brand = "Acme";
pr.tagline = "A stand-in priced product for the renderer contract.";
pr.status = "coming-soon";
pr.shipWeightGrams = 700;
pr.image = "/fixture-handheld.jpg";
pr.summary = "Stands in for a priced listing.";
pr.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to "
"Catcrafts, whatever turns out to be broken. The full terms are on the "
"terms page.";
pr.variants = {
{ "green", "Forest Green", 51330 + 6050 },
{ "black", "Black", 51930 + 6050 },
{ "white", "White", 60488 + 6050 },
};
pr.specs = {
{ "Display", "6.31″ OLED" },
{ "Memory", "8 GB RAM" },
};
pr.priceInclMinor = pr.CheapestVariant()->priceInclMinor;
return pr;
}
// ── the compiled-in catalogue ─────────────────────────────────────────
// What is asserted against the shipped data is the SHAPE every listing must
// have, over whatever is listed, plus the donation item's own contract. No
// slug, price or model is written here: a listing coming or going is a content
// change, not a test edit.
void CatalogueContract() {
using namespace Catcrafts::Money;
const auto& products = Content::Products();
// Three entries: the two phones, and the donation item that soft-opens
// the shop. The Gen. 6 stays FIRST — it is the headline, and the suites
// below address Products()[0] as the priced product — and the donation
// stays LAST.
Check(products.size() == 3, "content: three products");
if (products.size() == 3) {
const Product& pr = products[0];
Check(pr.slug == "fp6-pmos", "content: product slug");
// Coming-soon is the pre-launch state; launch flips it to
// "available" and this check keeps passing either way.
Check(pr.Buyable() || pr.ComingSoon(),
"content: product is buyable or deliberately coming soon");
Check(pr.variants.size() == 3, "content: three colours");
// Cost-plus pricing, derived in code: supplier + €60.50 gross markup,
// exactly — the gross-up of the €50 Catcrafts keeps after VAT. The
// margin identity itself (net of retail = net of supplier + 5000) is
// asserted below; these pin the resulting stickers.
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380,
"content: green = 513.30 supplier + 60.50 gross markup");
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980,
"content: black = 519.30 supplier + 60.50 gross markup");
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538,
"content: white = 604.88 supplier + 60.50 gross markup");
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant");
// The pricing rule as the user states it: after shipping (a pass-
// through) and VAT, every unit sold walks away with €50.00 — however
// the supplier moves. Checked against the COMPILED catalogue, per
// variant, in the same arithmetic the invoice and checkout use:
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
// has its own round-trip guarantee in ShouldComputeMoney, and
// Payment costs are the one accepted deviation, and on a bank
// transfer they are zero.
for (const auto& [slug, supplier] :
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
const Variant* v = pr.FindVariant(slug);
Check(v && Money::NetFromGross(v->priceInclMinor)
- Money::NetFromGross(supplier) == 5000,
"content: variant nets the supplier price plus exactly €50", slug);
Check(!products.empty(), "content: the catalogue is not empty");
// The donation item: exactly one, LAST (it is the odd entry out, not what
// the shop is about), and available — it is what the shop is open FOR
// whatever the goods are doing. Buyer names the amount, so no price, no
// variants, no weight — and the Buyable() price check is waived for
// exactly this shape.
std::size_t donations = 0;
for (const Product& p : products) donations += p.donation ? 1 : 0;
Check(donations == 1, "content: exactly one donation item");
const Product& don = products.back();
Check(don.donation && don.slug == "donation", "content: the donation item comes last");
Check(don.Buyable() && !don.ComingSoon(), "content: the donation item is on sale");
Check(don.priceInclMinor == 0 && don.variants.empty() && don.shipWeightGrams == 0,
"content: a donation has no price, no colours and no parcel");
Check(don.warranty.empty() && don.specs.empty(),
"content: a donation carries no spec sheet and no warranty");
// Its page: a donation form (amount + optional email), no price line, no
// product JSON-LD — an offer with no amount is a claim shopping crawlers
// can only misread — and no goods sections.
{
const auto dp = Views::RenderProduct(don, Rates{});
const std::string_view html = dp.main.View();
Check(html.find("name=\"amount\"") != std::string_view::npos,
"donation page: the form asks for an amount");
Check(html.find("name=\"street\"") == std::string_view::npos
&& html.find("name=\"country\"") == std::string_view::npos,
"donation page: no address is asked for — nothing ships");
Check(html.find("field__req") == std::string_view::npos
|| html.find("Email <span") == std::string_view::npos,
"donation page: email is not marked required");
Check(html.find("price--product") == std::string_view::npos,
"donation page: no price line for an unpriced item");
Check(html.find("Specifications") == std::string_view::npos
&& html.find("Warranty") == std::string_view::npos,
"donation page: no spec or warranty section renders");
Check(dp.meta.jsonLd.empty(),
"donation page: no product JSON-LD is published");
Check(!dp.meta.geoPriceHint,
"donation page: no price-hint script — nothing to convert");
}
// Every priced listing, whatever it is. These are the fields the checkout,
// the carrier lookup and the renderers dereference; a listing missing one
// is a content bug that must not sell.
for (const Product& pr : products) {
if (pr.donation) continue;
Check(ParseRoute("/shop/" + pr.slug).kind == RouteKind::Product,
"content: the slug is a URL", pr.slug);
Check(pr.Buyable() || pr.ComingSoon() || pr.status == "unavailable",
"content: status is available, coming-soon or unavailable", pr.slug);
Check(pr.priceInclMinor > 0, "content: a priced product has a price", pr.slug);
if (!pr.variants.empty()) {
// The from-price is the cheapest variant, derived so the two can
// never disagree, and no colour may be listed unpriced.
Check(pr.CheapestVariant()
&& pr.CheapestVariant()->priceInclMinor == pr.priceInclMinor,
"content: from-price is the cheapest variant", pr.slug);
for (const Variant& v : pr.variants) {
Check(v.priceInclMinor > 0 && !v.slug.empty() && !v.label.empty(),
"content: every colour is priced and named", pr.slug + "/" + v.slug);
}
}
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"content: cheapest is green");
// The boxed weight of one outgoing parcel. Not decoration: this
// single integer picks the carrier's weight bracket, so it decides
// the shipping cents added to every order AND the per-country
// quantity ceiling. Regressed to 0 or off by an order of magnitude,
// the shop quotes a rate the carrier does not honour on real parcels.
Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g");
{
// A ladder straddling that weight. 700 g fits both bands, and
// RateFor takes the CHEAPEST band that can carry it — 895, not
// the tighter-looking 5500 — while the ceiling comes off the
// heaviest band: 10000 / 700 = 14 units to a parcel.
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
"content: the shipped weight lands in the cheap carrier bracket");
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
"content: and caps one parcel at fourteen units");
}
// This used to assert the emergency-calling caveat was present. It
// came off on 2026-08-18, when imsd called 112 over a live network in
// an approved test session, so the assertion inverts: the page must
// not go back to telling buyers that path is unverified. A future
// safety claim may fill this field again — it just cannot be that one.
Check(pr.safetyNote.find("not yet verified") == std::string::npos,
"content: no stale unverified-emergency-calling caveat");
// The boxed weight of one outgoing parcel. Not decoration: this single
// integer picks the carrier's weight bracket, so it decides the
// shipping cents added to every order AND the per-country quantity
// ceiling. Zero would quote a rate the carrier does not honour.
Check(pr.shipWeightGrams > 0, "content: one boxed unit has a weight", pr.slug);
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
"content: warranty is written, not a placeholder");
// The product page's schema.org record must parse with our own
// JSON parser and carry one variant Product per colour, each
// with its ONE offer — built from the same integers the
// checkout charges.
{
// The listing's shipping block is now carrier data, so the
// render needs a table. Three of these four are priced on purpose
// and must still not be advertised: US is refused on insurance, DE
// and GB on their missing producer registrations. The carrier will
// happily quote all three, which is exactly why the filter is worth
// asserting — CH is the only one here besides home that sells.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "CH", { { 2000, 2450 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 3300 } } },
{ "US", { { 2000, 1794 } } },
};
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
auto ld = Json::Parse(pp.meta.jsonLd);
bool variantsOk = false;
if (ld && ld->IsObject()) {
if (const Json::Value* v = ld->Find("hasVariant");
v && v->IsArray() && v->array.size() == pr.variants.size()) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* o = node.Find("offers");
variantsOk = variantsOk && node.Str("@type") == "Product"
&& o && o->IsObject();
}
}
}
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
"schema: product JSON-LD parses, one variant per colour");
// Merchant-grade fields: shipping, returns, sku, group id —
// what Merchant Center's website-crawl feed reads at launch
// (productGroupID is its item_group_id).
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: variants carry shipping, returns, sku and group id");
// The published rates ARE the carrier's, at one unit's weight.
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table");
// And nothing the shop refuses is advertised, whatever the carrier
// quotes for it — by rate, so a filter that dropped the country code
// but kept the price would still be caught.
Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
"schema: refused destinations are never advertised");
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
// No table: no shipping claim. The listing loses the merchant
// block rather than inventing a rate — the whole point of
// dropping the zone fallback.
auto bare = Views::RenderProduct(pr, Rates{});
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
"schema: with no carrier table the offer publishes no shipping");
Check(Json::Parse(bare.meta.jsonLd).has_value()
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: and the rest of the record still parses");
}
// The Gen. 6+: the same contract as the phone above, asserted against
// its own numbers. That the two SHARE the pricing rule is the point —
// a second device must not quietly become a second pricing policy.
const Product& plus = products[1];
Check(plus.slug == "fp6plus-pmos", "content: the refresh is the second listing");
Check(plus.brand == "Fairphone", "content: the refresh names its manufacturer");
Check(plus.Buyable() || plus.ComingSoon(),
"content: the refresh is buyable or deliberately coming soon");
Check(plus.variants.size() == 3, "content: three colours on the refresh");
// Launch price in every colour, so all three stickers are €709.50 —
// and each still nets exactly €50 after VAT, by the same arithmetic
// the invoice and the checkout use.
for (std::string_view slug : { "green", "black", "blue" }) {
const Variant* v = plus.FindVariant(slug);
Check(v && v->priceInclMinor == 70950,
"content: refresh colour = 649.00 supplier + 60.50 gross markup", slug);
Check(v && Money::NetFromGross(v->priceInclMinor)
- Money::NetFromGross(64900) == 5000,
"content: refresh colour nets the supplier price plus exactly €50", slug);
}
Check(plus.FindVariant("white") == nullptr,
"content: a colour this model doesn't ship in is null");
Check(plus.CheapestVariant()
&& plus.priceInclMinor == plus.CheapestVariant()->priceInclMinor,
"content: refresh from-price is the cheapest variant");
// Same box, same 193 g device, so the same bracket and the same
// per-parcel ceiling the phone above is checked against.
Check(plus.shipWeightGrams == 700,
"content: the refresh leaves in the same 700 g parcel");
// One warranty text for both phones, not two that can drift — the
// reason it is a shared constant rather than a second paste.
Check(plus.warranty == pr.warranty,
"content: both phones carry the identical warranty text");
Check(plus.specs.size() == pr.specs.size(),
"content: the refresh states every spec row the Gen. 6 does");
// The refresh IS the faster silicon — if these two rows ever match the
// Gen. 6's, the listing is selling the wrong phone.
{
bool soc = false, ram = false;
for (const Spec& s : plus.specs) {
soc = soc || (s.label == "Processor" && s.value.find("7s Gen 4") != std::string::npos);
ram = ram || (s.label == "Memory" && s.value.find("12 GB") != std::string::npos);
}
Check(soc && ram, "content: the refresh lists its own SoC and 12 GB of RAM");
}
// Its own photo, not the Gen. 6's — the two phones ship in different
// colours, so sharing one render would picture a phone this listing
// cannot sell. Published, not merely committed: a file that the page,
// the link preview and the schema record all omit is a photo nobody
// ever sees, which is the failure the checks below actually catch.
Check(plus.image == "/fp6plus-pmos.jpg", "content: the refresh names its own photo");
Check(plus.image != pr.image, "content: and not the Gen. 6's");
// The pictured colour is the pre-selected one. With a single price
// across the range that is purely a question of list order, so it is
// worth pinning: a reorder would leave the page showing blue and
// selling green by default.
Check(plus.CheapestVariant() && plus.CheapestVariant()->slug == "blue",
"content: the default colour is the one the photo shows");
{
const auto pp = Views::RenderProduct(plus, Rates{});
// The spec lede is generated from the brand, so it must introduce
// THIS sheet — the old markup hardcoded "Fairphone (Gen. 6)" and
// would now caption the refresh's specs with the other phone's name.
Check(pp.main.View().find("a stock Fairphone, unmodified") != std::string_view::npos,
"refresh page: the spec lede names the brand, not a model");
Check(pp.main.View().find("stock Fairphone (Gen. 6)") == std::string_view::npos,
"refresh page: and does not caption these specs as the Gen. 6's");
Check(pp.main.View().find("product__photo") != std::string_view::npos,
"refresh page: the photo renders");
Check(pp.meta.ogImage == "/fp6plus-pmos.jpg",
"refresh page: the link preview names its own photo");
Check(pp.meta.jsonLd.find("\"image\":\"https://catcrafts.net/fp6plus-pmos.jpg\"")
!= std::string::npos,
"refresh page: and the schema record carries it absolute");
}
// The donation item: the shop's soft opening. Available (it is what
// the shop is open FOR) while both phones stay coming-soon; buyer
// names the amount, so no price, no variants, no weight — and the
// Buyable() price check is waived for exactly this shape.
const Product& don = products[2];
Check(don.slug == "donation" && don.donation,
"content: the donation item comes last");
Check(don.Buyable() && !don.ComingSoon(),
"content: the donation item is on sale while the phones are not");
Check(don.priceInclMinor == 0 && don.variants.empty()
&& don.shipWeightGrams == 0,
"content: a donation has no price, no colours and no parcel");
Check(don.warranty.empty() && don.specs.empty(),
"content: a donation carries no spec sheet and no warranty");
// Its page: a donation form (amount + optional email), no price line,
// no product JSON-LD — an offer with no amount is a claim shopping
// crawlers can only misread — and no Fairphone sections.
{
const auto dp = Views::RenderProduct(don, Rates{});
const std::string_view html = dp.main.View();
Check(html.find("name=\"amount\"") != std::string_view::npos,
"donation page: the form asks for an amount");
Check(html.find("name=\"street\"") == std::string_view::npos
&& html.find("name=\"country\"") == std::string_view::npos,
"donation page: no address is asked for — nothing ships");
Check(html.find("field__req") == std::string_view::npos
|| html.find("Email <span") == std::string_view::npos,
"donation page: email is not marked required");
Check(html.find("price--product") == std::string_view::npos,
"donation page: no price line for an unpriced item");
Check(html.find("Specifications") == std::string_view::npos
&& html.find("Warranty") == std::string_view::npos,
"donation page: no spec or warranty section renders");
Check(dp.meta.jsonLd.empty(),
"donation page: no product JSON-LD is published");
Check(!dp.meta.geoPriceHint,
"donation page: no price-hint script — nothing to convert");
"content: warranty is written, not a placeholder", pr.slug);
Check(pr.safetyNote.find("not yet verified") == std::string::npos,
"content: no stale unverified-emergency-calling caveat", pr.slug);
// Published, not merely committed: the photo the page, the link preview
// and the schema record name has to exist in the tree, or nobody sees it.
if (!pr.image.empty()) {
Check(std::filesystem::exists("images" + pr.image),
"content: the photo exists in the repo", pr.image);
}
// Its record parses with our own JSON parser and is sold by the
// registered organisation — the join the identity graph depends on.
const auto pp = Views::RenderProduct(pr, Rates{});
Check(Json::Parse(pp.meta.jsonLd).has_value(),
"content: the product record parses", pr.slug);
Check(pp.meta.jsonLd.find("https://catcrafts.net/#organization") != std::string::npos,
"content: the offer is sold by the organization node", pr.slug);
}
// The spec lede is "a stock <brand>, unmodified", so a spec sheet with no
// brand renders a sentence with a hole in it. Cheaper to assert than to
@ -301,6 +174,134 @@ void CatalogueContract() {
Check(!Content::Demos().empty(), "content: demos present");
}
// ── the product page, against the fixture ─────────────────────────────
// The renderer's contract for a priced listing, pinned on the hand-built
// product above so it holds while the catalogue lists none.
void RendererContract() {
using namespace Catcrafts::Money;
const Product pr = Fixture();
Check(pr.FindVariant("mauve") == nullptr, "fixture: unknown colour is null");
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"fixture: cheapest is green");
// The pricing rule as the user states it: after shipping (a pass-through)
// and VAT, every unit sold walks away with €50.00 — however the supplier
// moves. net(retail) - net(supplier) must be exactly 5000 minor, in the
// same arithmetic the invoice and checkout use.
for (const auto& [slug, supplier] :
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
const Variant* v = pr.FindVariant(slug);
Check(v && NetFromGross(v->priceInclMinor) - NetFromGross(supplier) == 5000,
"fixture: variant nets the supplier price plus exactly €50", slug);
}
{
// A ladder straddling the boxed weight. 700 g fits both bands, and
// RateFor takes the CHEAPEST band that can carry it — 895, not the
// tighter-looking 5500 — while the ceiling comes off the heaviest band:
// 10000 / 700 = 14 units to a parcel.
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
"fixture: the shipped weight lands in the cheap carrier bracket");
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
"fixture: and caps one parcel at fourteen units");
}
// The product page's schema.org record must parse with our own JSON parser
// and carry one variant Product per colour, each with its ONE offer — built
// from the same integers the checkout charges.
{
// The listing's shipping block is carrier data, so the render needs a
// table. Three of these four are priced on purpose and must still not
// be advertised: US is refused by policy, DE and GB on their missing
// producer registrations. The carrier will happily quote all three,
// which is exactly why the filter is worth asserting — CH is the only
// one here besides home that sells.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "CH", { { 2000, 2450 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 3300 } } },
{ "US", { { 2000, 1794 } } },
};
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
auto ld = Json::Parse(pp.meta.jsonLd);
bool variantsOk = false;
if (ld && ld->IsObject()) {
if (const Json::Value* v = ld->Find("hasVariant");
v && v->IsArray() && v->array.size() == pr.variants.size()) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* o = node.Find("offers");
variantsOk = variantsOk && node.Str("@type") == "Product"
&& o && o->IsObject();
}
}
}
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
"schema: product JSON-LD parses, one variant per colour");
Check(pp.meta.jsonLd.find("\"brand\":{\"@type\":\"Brand\",\"name\":\"Acme\"}")
!= std::string::npos,
"schema: the group carries the hardware brand");
// Merchant-grade fields: shipping, returns, sku, group id — what
// Merchant Center's website-crawl feed reads (productGroupID is its
// item_group_id).
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& pp.meta.jsonLd.find("\"sku\":\"fixture-handheld-green\"") != std::string::npos
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: variants carry shipping, returns, sku and group id");
// The published rates ARE the carrier's, at one unit's weight.
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table");
// And nothing the shop refuses is advertised, whatever the carrier
// quotes for it — by rate, so a filter that dropped the country code
// but kept the price would still be caught.
Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
"schema: refused destinations are never advertised");
Check(pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
Check(pp.meta.jsonLd.find("https://catcrafts.net/#organization") != std::string::npos,
"schema: offers are sold by the organization node");
// The photo travels three ways: the page, the link preview, the record.
Check(pp.main.View().find("product__photo") != std::string_view::npos,
"product page: the photo renders");
Check(pp.meta.ogImage == pr.image, "product page: the link preview names the photo");
Check(pp.meta.jsonLd.find("\"image\":\"https://catcrafts.net/fixture-handheld.jpg\"")
!= std::string::npos,
"product page: and the schema record carries it absolute");
// The spec lede is generated from the brand, so it introduces THIS
// sheet — and claims nothing about any particular model or software.
Check(pp.main.View().find("a stock Acme, unmodified") != std::string_view::npos,
"product page: the spec lede names the brand, not a model");
Check(pp.main.View().find("postmarketOS") == std::string_view::npos,
"product page: the renderer names no software of its own");
Check(pp.main.View().find("<h2 class=\"section__title\">Warranty</h2>")
!= std::string_view::npos,
"product page: the warranty section renders where a text exists");
// No table: no shipping claim. The listing loses the merchant block
// rather than inventing a rate — the whole point of dropping the zone
// fallback.
auto bare = Views::RenderProduct(pr, Rates{});
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
"schema: with no carrier table the offer publishes no shipping");
Check(Json::Parse(bare.meta.jsonLd).has_value()
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: and the rest of the record still parses");
// Coming-soon publishes the price and closes the form.
Check(bare.meta.jsonLd.find("schema.org/PreOrder") != std::string::npos,
"schema: coming-soon maps to PreOrder availability");
Check(bare.main.View().find("<form") == std::string_view::npos
&& bare.main.View().find("Coming soon") != std::string_view::npos,
"product page: coming-soon renders the notice and no order form");
}
}
// ── the identity graph ────────────────────────────────────────────────
// "Catcrafts" is two common words with no space, so it competes with a
// decade of kids' craft blogs, Etsy and a Minecraft server on the
@ -366,8 +367,11 @@ void IdentityGraph() {
auto about = Views::RenderAbout(Content::AboutPage());
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: about joins the founder to the organization node");
if (!Content::Products().empty()) {
auto pp = Views::RenderProduct(Content::Products()[0], Rates{});
{
// The offer side of the join, on the fixture: the catalogue may list
// no priced product (the donation publishes no offer at all), and the
// join must hold for the next one regardless.
auto pp = Views::RenderProduct(Fixture(), Rates{});
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: offers are sold by the organization node");
}
@ -382,9 +386,9 @@ void IdentityGraph() {
}
// ── the sale gates, built by hand ─────────────────────────────────────
// The shipped catalogue is one product in one status with three colours, so
// asserting against it can only ever exercise one arm of each guard. These
// products exist to reach the others.
// The shipped catalogue holds each status at most once (today: the donation
// alone), so asserting against it can only ever exercise one arm of each
// guard. These products exist to reach the others.
//
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
// conjunction: status AND a price. ComingSoon() is separately what decides
@ -454,16 +458,15 @@ void SaleGates() {
}
// ── the product page's live-total blob ────────────────────────────────
// RenderCheckoutForm renders only for a Buyable product, and the shipped
// catalogue is coming-soon, so today nothing renders it: the two suites that
// read this attribute both sit behind a ShopOpen() gate. Flip a copy to
// "available" and read the markup here instead — the refusal lists in that
// blob are what make the on-page total decline in exactly the places
// RenderCheckoutForm renders only for a Buyable priced product, and the
// shipped catalogue lists none, so nothing in it renders the form: the suites
// that read this attribute over HTTP sit behind a ShopOpen() gate. Flip the
// fixture to "available" and read the markup here instead — the refusal lists
// in that blob are what make the on-page total decline in exactly the places
// checkout declines, and a page that quotes a total for a sanctioned or
// no-sale destination invites an order that must then be refused.
void CheckoutPreviewData() {
if (Content::Products().empty()) return;
Product pr = Content::Products()[0];
Product pr = Fixture();
pr.status = "available";
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
@ -583,6 +586,7 @@ void AdvertisedUrls() {
int main() {
CatalogueContract();
RendererContract();
IdentityGraph();
SaleGates();
CheckoutPreviewData();