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

@ -165,6 +165,11 @@ int main(int argc, char** argv) {
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

View file

@ -38,7 +38,26 @@ int main(int argc, char** argv) {
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
}
// The donation item: open while the phone above may not be — the shop's
// The Gen. 6+ beside it: one launch price in all three colours, so the
// "from" price and every selector row are the same number — and the
// ex-VAT twin is still derived from it rather than typed in.
srv.BodyHas("/shop/fp6plus-pmos", "€709.50",
"refresh page shows the from-price (€649.00 supplier + €60.50 gross markup)");
srv.BodyHas("/shop/fp6plus-pmos", "€586.36", "refresh page shows the derived ex-VAT price");
srv.BodyHas("/shop", "€709.50", "shop card shows the refresh from-price");
srv.BodyHas("/shop/fp6plus-pmos", "Cobalt Blue €709.50",
"colour selector prices the colour only this model ships in");
srv.BodyHas("/shop/fp6plus-pmos", "12 GB RAM",
"refresh page states the spec that makes it a different phone");
srv.BodyHas("/shop/fp6plus-pmos", "src=\"/fp6plus-pmos.jpg\"",
"refresh page embeds its own photo");
srv.BodyHas("/shop", "src=\"/fp6plus-pmos.jpg\"", "shop card embeds the refresh thumbnail");
Check(std::filesystem::exists("images/fp6plus-pmos.jpg"),
"refresh photo exists in the repo");
srv.BodyLacks("/shop/fp6plus-pmos", "stock Fairphone (Gen. 6),",
"refresh page does not caption its specs as the Gen. 6's");
// The donation item: open while the phones above may not be — the shop's
// soft opening. Its card quotes no price (there is none), its page is a
// form asking an amount and, optionally, an email — never an address.
srv.BodyHas("/shop", "/shop/donation", "shop grid lists the donation item");

View file

@ -19,7 +19,8 @@ int main(int argc, char** argv) {
TestServer srv(argv[1], 8210);
// ── status codes ──────────────────────────────────────────────────
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/projects",
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
"/shop/fp6plus-pmos", "/shop/donation", "/projects",
"/posts", "/demos", "/demos/raytracer", "/financials",
"/legal/privacy", "/legal/terms", "/legal/imprint",
"/feed.xml", "/sitemap.xml", "/api/healthz" }) {
@ -72,6 +73,8 @@ int main(int argc, char** argv) {
// ── sitemap and feed content ──────────────────────────────────────
srv.BodyHas("/sitemap.xml", "/shop/fp6-pmos", "sitemap lists the product");
srv.BodyHas("/sitemap.xml", "/shop/fp6plus-pmos",
"sitemap lists the refresh — it comes off the catalogue, not a second list");
srv.BodyHas("/sitemap.xml", "/about", "sitemap lists the about page");
srv.BodyHas("/sitemap.xml", "/legal/privacy", "sitemap lists the privacy page");
srv.BodyHas("/sitemap.xml", "/demos", "sitemap lists the demos page");

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

View file

@ -53,7 +53,7 @@ int main(int argc, char** argv) {
// Placeholders are dev-only markers; one reaching production is a content
// bug (an imprint that says PLACEHOLDER once shipped exactly that way).
for (std::string_view pg : { "/legal/privacy", "/legal/terms", "/legal/imprint",
"/shop/fp6-pmos", "/financials" }) {
"/shop/fp6-pmos", "/shop/fp6plus-pmos", "/financials" }) {
srv.BodyLacks(std::string(pg), "PLACEHOLDER",
std::format("{} ships no placeholder markers", pg));
}
@ -65,7 +65,7 @@ int main(int argc, char** argv) {
// rule. Pin the shape hard: inline only (no src=, so nothing external can
// ever ride in under this exception), no network APIs, and the page must
// remain complete without it — both prices in the markup regardless.
for (std::string_view pg : { "/shop", "/shop/fp6-pmos" }) {
for (std::string_view pg : { "/shop", "/shop/fp6-pmos", "/shop/fp6plus-pmos" }) {
const std::string body = srv.Body(std::string(pg));
const std::size_t n = CountOccurrences(body, "<script>");
Check(n == 1,