fp6+
All checks were successful
Deploy / build-deploy (push) Successful in 3m6s

This commit is contained in:
Jorijn van der Graaf 2026-08-19 17:19:51 +02:00
commit ed61fb5ca5
12 changed files with 254 additions and 38 deletions

View file

@ -31,11 +31,12 @@ void CatalogueContract() {
using namespace Catcrafts::Money;
const auto& products = Content::Products();
// Two entries: the phone, and the donation item that soft-opens the shop.
// The phone stays FIRST — it is the headline, and the suites below
// address Products()[0] as the priced product.
Check(products.size() == 2, "content: two products");
if (products.size() == 2) {
// 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
@ -167,15 +168,91 @@ void CatalogueContract() {
"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 the phone stays coming-soon; buyer
// 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[1];
const Product& don = products[2];
Check(don.slug == "donation" && don.donation,
"content: the second product is the donation item");
"content: the donation item comes last");
Check(don.Buyable() && !don.ComingSoon(),
"content: the donation item is on sale while the phone is not");
"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");
@ -206,6 +283,13 @@ void CatalogueContract() {
"donation page: no price-hint script — nothing to convert");
}
}
// 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
// branch around in the renderer.
for (const Product& any : products) {
Check(any.specs.empty() || !any.brand.empty(),
"content: a product with a spec sheet names its manufacturer", any.slug);
}
Check(!Content::Projects().empty(), "content: projects present");
Check(Content::LegalPages().size() == 3, "content: three legal pages");
Check(Content::AboutPage().sections.size() >= 3