This commit is contained in:
parent
eb655b24b6
commit
25805a093a
20 changed files with 788 additions and 805 deletions
|
|
@ -8,8 +8,8 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
|
||||
// The JSON-LD blocks are the machine-readable identity and offer records,
|
||||
// born from a Google AI overview flatly asserting that nobody named Catcrafts
|
||||
// sells Fairphone hardware. Parsed with our own JSON reader — the same one
|
||||
// that must accept them — and checked for the facts.
|
||||
// sells the hardware it then sold. Parsed with our own JSON reader — the same
|
||||
// one that must accept them — and checked for the facts.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
|
@ -64,28 +64,7 @@ int main(int argc, char** argv) {
|
|||
Check(ok, "home Organization schema parses and carries the VAT identity");
|
||||
}
|
||||
|
||||
// ── the product record ────────────────────────────────────────────
|
||||
// Variants are a ProductGroup: one variant Product per colour, each with
|
||||
// its own single offer — not one Product with three prices.
|
||||
const std::vector<Json::Value> productLd = ExtractLd(srv, "/shop/fp6-pmos");
|
||||
const Json::Value* group = nullptr;
|
||||
for (const Json::Value& doc : productLd) {
|
||||
if (doc.Str("@type") == "ProductGroup") group = &doc;
|
||||
}
|
||||
{
|
||||
bool variantsOk = false;
|
||||
if (group) {
|
||||
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
|
||||
&& v->array.size() == 3) {
|
||||
variantsOk = true;
|
||||
for (const Json::Value& node : v->array) {
|
||||
const Json::Value* offers = node.Find("offers");
|
||||
variantsOk = variantsOk && offers && offers->IsObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(variantsOk, "product schema parses with one variant (and offer) per colour");
|
||||
}
|
||||
// ── the shop index ────────────────────────────────────────────────
|
||||
{
|
||||
bool listOk = false;
|
||||
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
|
||||
|
|
@ -96,80 +75,117 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
Check(listOk, "shop index carries an ItemList of the product pages");
|
||||
}
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"573.80\"", "schema price is the checkout integer");
|
||||
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
||||
// reads. Shipping is published from the live carrier table at one unit's
|
||||
// weight — the same integers checkout charges, so the listing cannot
|
||||
// quote a rate the till won't honour; returns mirror the terms page.
|
||||
srv.BodyHas("/shop/fp6-pmos", "OfferShippingDetails", "offers carry shipping details");
|
||||
srv.BodyHas("/shop/fp6-pmos", "MerchantReturnPolicy", "offers carry a return policy");
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"sku\":\"fp6-pmos-green\"", "offers carry per-variant skus");
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
|
||||
"product carries the hardware brand");
|
||||
|
||||
// One entry per (transit tier, price) the carrier table produces, for the
|
||||
// destinations the shop actually sells to — two, since the fixture prices
|
||||
// NL/DE/GB/CH and checkout refuses DE and GB pending their producer
|
||||
// registrations. Not a fixed property of the code: it is whatever the
|
||||
// carrier prices INTERSECTED with where the shop sells, and publishing a
|
||||
// rate to a country checkout would decline is an offer that cannot be
|
||||
// accepted.
|
||||
const Json::Value* shipping = nullptr;
|
||||
if (group) {
|
||||
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
|
||||
&& !v->array.empty()) {
|
||||
if (const Json::Value* offers = v->array[0].Find("offers")) {
|
||||
shipping = offers->Find("shippingDetails");
|
||||
}
|
||||
// ── the product record ────────────────────────────────────────────
|
||||
// Against whatever priced product the catalogue lists first, with every
|
||||
// expected value read off that record: a listing coming or going is a
|
||||
// content change, not an edit here. While no goods are listed (as since
|
||||
// 2026-09-05) this block prints a note and is skipped.
|
||||
if (const Product* pr = FirstPricedProduct()) {
|
||||
const std::string shop = "/shop/" + pr->slug;
|
||||
// Variants are a ProductGroup: one variant Product per colour, each
|
||||
// with its own single offer — not one Product with three prices. A
|
||||
// single-colour or colourless listing is a plain Product with one offer.
|
||||
const bool grouped = pr->variants.size() > 1;
|
||||
// Kept alive for the block: `record` and `firstOffer` point into it.
|
||||
const std::vector<Json::Value> productLd = ExtractLd(srv, shop);
|
||||
const Json::Value* record = nullptr;
|
||||
for (const Json::Value& doc : productLd) {
|
||||
if (doc.Str("@type") == (grouped ? "ProductGroup" : "Product")) record = &doc;
|
||||
}
|
||||
}
|
||||
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
|
||||
"shipping details group the carrier's rates");
|
||||
// The advertised rate IS the carrier's single-unit price. Neither a
|
||||
// destination the table does not cover (AU) nor one the policy refuses
|
||||
// (DE, priced at €25 in the fixture) is ever advertised.
|
||||
if (shipping && shipping->IsArray()) {
|
||||
std::vector<std::string> rates;
|
||||
bool au = false;
|
||||
bool refused = false;
|
||||
for (const Json::Value& detail : shipping->array) {
|
||||
if (const Json::Value* rate = detail.Find("shippingRate")) {
|
||||
rates.emplace_back(rate->Str("value"));
|
||||
const Json::Value* firstOffer = nullptr;
|
||||
{
|
||||
bool offersOk = false;
|
||||
if (record && grouped) {
|
||||
if (const Json::Value* v = record->Find("hasVariant"); v && v->IsArray()
|
||||
&& v->array.size() == pr->variants.size()) {
|
||||
offersOk = true;
|
||||
for (const Json::Value& node : v->array) {
|
||||
const Json::Value* offers = node.Find("offers");
|
||||
offersOk = offersOk && offers && offers->IsObject();
|
||||
}
|
||||
if (!v->array.empty()) firstOffer = v->array[0].Find("offers");
|
||||
}
|
||||
} else if (record) {
|
||||
firstOffer = record->Find("offers");
|
||||
offersOk = firstOffer && firstOffer->IsObject();
|
||||
}
|
||||
if (const Json::Value* dest = detail.Find("shippingDestination")) {
|
||||
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
|
||||
for (const Json::Value& c : cc->array) {
|
||||
au = au || c.string == "AU";
|
||||
refused = refused || c.string == "DE" || c.string == "GB";
|
||||
Check(offersOk, grouped
|
||||
? "product schema parses with one variant (and offer) per colour"
|
||||
: "product schema parses with its one offer");
|
||||
}
|
||||
srv.BodyHas(shop, "\"price\":\"" + Money::FormatMinor(pr->priceInclMinor) + "\"",
|
||||
"schema price is the checkout integer");
|
||||
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
||||
// reads. Shipping is published from the live carrier table at one unit's
|
||||
// weight — the same integers checkout charges, so the listing cannot
|
||||
// quote a rate the till won't honour; returns mirror the terms page.
|
||||
srv.BodyHas(shop, "OfferShippingDetails", "offers carry shipping details");
|
||||
srv.BodyHas(shop, "MerchantReturnPolicy", "offers carry a return policy");
|
||||
const std::string sku = pr->variants.empty()
|
||||
? pr->slug : pr->slug + "-" + pr->variants[0].slug;
|
||||
srv.BodyHas(shop, "\"sku\":\"" + sku + "\"", "offers carry per-variant skus");
|
||||
if (!pr->brand.empty()) {
|
||||
srv.BodyHas(shop, "\"brand\":{\"@type\":\"Brand\",\"name\":\"" + pr->brand + "\"}",
|
||||
"product carries the hardware brand");
|
||||
}
|
||||
|
||||
// One entry per (transit tier, price) the carrier table produces, for
|
||||
// the destinations the shop actually sells to — two, since the fixture
|
||||
// prices NL/DE/GB/CH and checkout refuses DE and GB pending their
|
||||
// producer registrations. Not a fixed property of the code: it is
|
||||
// whatever the carrier prices INTERSECTED with where the shop sells,
|
||||
// and publishing a rate to a country checkout would decline is an
|
||||
// offer that cannot be accepted.
|
||||
const Json::Value* shipping = firstOffer ? firstOffer->Find("shippingDetails") : nullptr;
|
||||
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
|
||||
"shipping details group the carrier's rates");
|
||||
// The advertised rate IS the carrier's single-unit price. Neither a
|
||||
// destination the table does not cover (AU) nor one the policy refuses
|
||||
// (DE, priced at €25 in the fixture) is ever advertised.
|
||||
if (shipping && shipping->IsArray()) {
|
||||
std::vector<std::string> rates;
|
||||
bool au = false;
|
||||
bool refused = false;
|
||||
for (const Json::Value& detail : shipping->array) {
|
||||
if (const Json::Value* rate = detail.Find("shippingRate")) {
|
||||
rates.emplace_back(rate->Str("value"));
|
||||
}
|
||||
if (const Json::Value* dest = detail.Find("shippingDestination")) {
|
||||
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
|
||||
for (const Json::Value& c : cc->array) {
|
||||
au = au || c.string == "AU";
|
||||
refused = refused || c.string == "DE" || c.string == "GB";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::ranges::sort(rates);
|
||||
Check(rates == std::vector<std::string>{ "15.00", "55.00" },
|
||||
"published shipping rates come from the carrier table");
|
||||
Check(!au, "an uncovered destination is not advertised");
|
||||
Check(!refused, "a destination the policy refuses is not advertised");
|
||||
}
|
||||
std::ranges::sort(rates);
|
||||
Check(rates == std::vector<std::string>{ "15.00", "55.00" },
|
||||
"published shipping rates come from the carrier table");
|
||||
Check(!au, "an uncovered destination is not advertised");
|
||||
Check(!refused, "a destination the policy refuses is not advertised");
|
||||
}
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
srv.BodyHas("/shop/fp6-pmos", "schema.org/InStock",
|
||||
"open shop maps to InStock availability");
|
||||
if (srv.ShopOpen(pr->slug)) {
|
||||
srv.BodyHas(shop, "schema.org/InStock", "open shop maps to InStock availability");
|
||||
} else {
|
||||
srv.BodyHas(shop, "schema.org/PreOrder", "coming-soon maps to PreOrder availability");
|
||||
}
|
||||
|
||||
// og: tags are the link-preview card on Mastodon and Lemmy — where the
|
||||
// traffic actually comes from. Per product, not per site: two listings
|
||||
// that share a link-preview image are two posts that look like the
|
||||
// same thing.
|
||||
if (!pr->image.empty()) {
|
||||
srv.BodyHas(shop, "og:image\" content=\"https://catcrafts.net" + pr->image,
|
||||
"product og:image is the absolute photo URL");
|
||||
}
|
||||
} else {
|
||||
srv.BodyHas("/shop/fp6-pmos", "schema.org/PreOrder",
|
||||
"coming-soon maps to PreOrder availability");
|
||||
std::println("note: the catalogue lists no priced product; the product-record "
|
||||
"checks did not run and re-arm when one is listed");
|
||||
}
|
||||
|
||||
// og: tags are the link-preview card on Mastodon and Lemmy — where the
|
||||
// traffic actually comes from.
|
||||
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
|
||||
srv.BodyHas("/shop/fp6-pmos", "og:image\" content=\"https://catcrafts.net/fp6-pmos.jpg",
|
||||
"product og:image is the absolute photo URL");
|
||||
// Per product, not per site: two phones that share a link-preview image
|
||||
// are two Mastodon posts that look like the same phone.
|
||||
srv.BodyHas("/shop/fp6plus-pmos",
|
||||
"og:image\" content=\"https://catcrafts.net/fp6plus-pmos.jpg",
|
||||
"the refresh previews with its own photo, not the Gen. 6's");
|
||||
|
||||
// ── the person-company weld ───────────────────────────────────────
|
||||
// The about page: the founder must be named in the HTML, the Person
|
||||
|
|
|
|||
Loading…
Reference in a new issue