This commit is contained in:
parent
eb655b24b6
commit
25805a093a
20 changed files with 788 additions and 805 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue