This commit is contained in:
parent
a8bc400371
commit
ed61fb5ca5
12 changed files with 254 additions and 38 deletions
|
|
@ -693,9 +693,9 @@ split working as designed.
|
|||
tools/fetch-posts.sh # pull posts from the allowed communities
|
||||
tools/fetch-media.sh [dir] # mirror their media locally (run after the above)
|
||||
tools/fetch-rates.sh # ECB reference rates for the indicative prices
|
||||
crafter-build --local -r # the wasm app alone, no backend, on :8080
|
||||
crafter-build -r # the wasm app alone, no backend, on :8080
|
||||
|
||||
crafter-build test --product=server --local # every suite: the unit assertions
|
||||
crafter-build test --product=server # every suite: the unit assertions
|
||||
# AND the black-box HTTP suites, which spawn the
|
||||
# real server on scratch ports (the CI gate; the
|
||||
# checkout lifecycle re-arms at launch)
|
||||
|
|
@ -706,8 +706,8 @@ crafter-build test --product=server --local # every suite: the unit assertions
|
|||
|
||||
### If a `bin/` glob matches two directories
|
||||
|
||||
The variant directory name embeds a config hash, and **`--local` and
|
||||
non-`--local` builds hash differently**: `--local` resolves the Crafter libraries
|
||||
The variant directory name embeds a config hash, and **`` and
|
||||
non-`` builds hash differently**: `` resolves the Crafter libraries
|
||||
from sibling working trees, a plain build fetches them from Forgejo. They are
|
||||
genuinely different configurations and both land in `bin/`.
|
||||
|
||||
|
|
|
|||
BIN
images/fp6plus-pmos.jpg
Normal file
BIN
images/fp6plus-pmos.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
17
project.cpp
17
project.cpp
|
|
@ -162,7 +162,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
std::vector<std::string> netArgs(args.begin(), args.end());
|
||||
bool useLocalNet = false;
|
||||
for (std::string_view a : args) {
|
||||
if (a == "--local") { useLocalNet = true; break; }
|
||||
if (a == "") { useLocalNet = true; break; }
|
||||
}
|
||||
Configuration* network = useLocalNet
|
||||
? LocalProject({
|
||||
|
|
@ -275,16 +275,16 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
std::vector<std::string> depArgs(args.begin(), args.end());
|
||||
depArgs.push_back("--target=wasm32-wasip1");
|
||||
|
||||
// --local resolves the Crafter.Graphics dep from a sibling working tree
|
||||
// resolves the Crafter.Graphics dep from a sibling working tree
|
||||
// instead of fetching it from forgejo. Mirrors Crafter.Graphics's own
|
||||
// project.cpp convention so edits across the two repos pick up without
|
||||
// commit-and-pull.
|
||||
bool useLocal = false;
|
||||
for (std::string_view a : args) {
|
||||
if (a == "--local") { useLocal = true; break; }
|
||||
if (a == "") { useLocal = true; break; }
|
||||
}
|
||||
if (useLocal && std::find(depArgs.begin(), depArgs.end(), std::string("--local")) == depArgs.end()) {
|
||||
depArgs.push_back("--local");
|
||||
if (useLocal && std::find(depArgs.begin(), depArgs.end(), std::string("")) == depArgs.end()) {
|
||||
depArgs.push_back("");
|
||||
}
|
||||
|
||||
Configuration* graphics = useLocal
|
||||
|
|
@ -355,10 +355,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
// EnableWasiBrowserRuntime — sets <title>/<link> tags since the
|
||||
// Dom partition has no head-element access.
|
||||
cfg.files.emplace_back(fs::path("catcrafts-head.js"));
|
||||
// Product photography. CC BY-SA 4.0, © Fairphone (official render via
|
||||
// Wikimedia Commons) — the attribution lives on /legal/imprint. NOT in
|
||||
// Product photography. Both renders are © Fairphone under CC BY-SA 4.0 and
|
||||
// are cropped and recompressed here — the Gen. 6 one came via Wikimedia
|
||||
// Commons, the Gen. 6+ one from Fairphone's own Flickr. The attribution and
|
||||
// the adaptation notice the licence requires live on /legal/imprint. NOT in
|
||||
// cfg.assets: that pipeline transcodes to .ctex, which <img> cannot decode.
|
||||
cfg.files.emplace_back(fs::path("images/fp6-pmos.jpg"));
|
||||
cfg.files.emplace_back(fs::path("images/fp6plus-pmos.jpg"));
|
||||
// ECB rates ride with the content so the wasm-rendered shop (backend-down
|
||||
// fallback) can emit the same indicative prices the server does.
|
||||
cfg.files.emplace_back(fs::path("content/rates.json"));
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ int main(int argc, char** argv) {
|
|||
// --routes: status + title for every route, for a quick smoke check.
|
||||
if (has("--routes")) {
|
||||
const Views::SiteContent content = LoadContent("content");
|
||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
|
||||
"/shop/fp6plus-pmos", "/shop/donation", "/shop/nope",
|
||||
"/financials",
|
||||
"/order/0123456789abcdef0123456789abcdef",
|
||||
"/order/not-a-token",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ static_assert(kMarkupMinor * 10000 % (10000 + Money::kVatRateBp) == 0,
|
|||
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
|
||||
"the exact net of the markup must be the €50 the rule promises");
|
||||
|
||||
// The warranty both phones carry, verbatim. Shared rather than pasted per
|
||||
// product because it is a promise about how Catcrafts handles a fault, not a
|
||||
// statement about a particular board — and two copies of a legal text are two
|
||||
// texts that can drift apart, while /legal/terms states exactly one policy.
|
||||
inline constexpr std::string_view kPhoneWarranty =
|
||||
"Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. User inflicted damage, including software faults caused by relocking the bootloader do not fall under this policy. The full terms are on the terms page.";
|
||||
|
||||
export const std::vector<Product>& Products() {
|
||||
static const std::vector<Product> products = [] {
|
||||
Product p;
|
||||
|
|
@ -72,7 +79,7 @@ export const std::vector<Product>& Products() {
|
|||
p.shipWeightGrams = 700;
|
||||
p.image = "/fp6-pmos.jpg";
|
||||
p.summary = "The Fairphone (Gen. 6), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
||||
p.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. User inflicted damage, including software faults caused by relocking the bootloader do not fall under this policy. The full terms are on the terms page.";
|
||||
p.warranty = kPhoneWarranty;
|
||||
// No safety note. There was one — the emergency-calling caveat — and
|
||||
// it came off on 2026-08-18, when imsd placed calls to 112 on a live
|
||||
// network in a test session approved by TB112 (the Dutch 1-1-2
|
||||
|
|
@ -111,15 +118,93 @@ export const std::vector<Product>& Products() {
|
|||
p.priceInclMinor = cheapest->priceInclMinor;
|
||||
}
|
||||
|
||||
// The Fairphone (Gen. 6+): Fairphone's mid-generation refresh, announced
|
||||
// 2026-08-18. A second listing rather than more colours on the entry
|
||||
// above — different silicon, a different price and its own spec sheet,
|
||||
// and the two are bought as different phones.
|
||||
//
|
||||
// It is sellable at all because the SOFTWARE is unchanged: the same
|
||||
// postmarketOS image, the same kernel patches and the same imsd run on
|
||||
// this board, confirmed on a device. The chassis, modem, cameras and
|
||||
// battery are the Gen. 6's; the SoC and the RAM are the only parts the
|
||||
// image had to be re-verified against, and it was.
|
||||
Product q;
|
||||
q.slug = "fp6plus-pmos";
|
||||
q.name = "The Fairphone (Gen. 6+) with postmarketOS";
|
||||
q.brand = "Fairphone";
|
||||
q.tagline = "The mid-generation refresh faster chip, 12 GB of RAM, running the same mainline Linux image.";
|
||||
q.status = "coming-soon";
|
||||
// Same device weight (193 g) in the same retail box, so the outgoing
|
||||
// parcel is the Gen. 6's parcel and lands in the same carrier bracket.
|
||||
// The rounding note on the entry above applies here too: this wants a
|
||||
// scale reading of a real parcel before launch, not a second guess.
|
||||
q.shipWeightGrams = 700;
|
||||
// Fairphone's own Cobalt Blue render, cropped from the 2200 × 2200
|
||||
// original on their Flickr — same rightsholder and same CC BY-SA 4.0
|
||||
// licence as the Gen. 6 render above, credited with the adaptation
|
||||
// notice on /legal/imprint. Deliberately NOT one of the copies the tech
|
||||
// press hosts: an outlet's file carries no licence grant from the
|
||||
// outlet, and the ones in circulation show the phone running stock
|
||||
// Android with the Play Store on screen — which is the single thing
|
||||
// this listing exists to sell the absence of.
|
||||
q.image = "/fp6plus-pmos.jpg";
|
||||
q.summary = "The Fairphone (Gen. 6+), reflashed by Catcrafts to run postmarketOS with the same patches and IMS/VoLTE implementation as the Gen. 6 — that image is confirmed working on this board. The hardware is Fairphone's mid-generation refresh: a faster Snapdragon and 12 GB of RAM in the same repairable chassis, with the same twelve replaceable modules and spare parts through 2033. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
||||
q.warranty = kPhoneWarranty;
|
||||
// Supplier €649.00 incl VAT in every colour — Fairphone's own launch
|
||||
// price, which is what the device costs anywhere a day after launch and
|
||||
// the only real number that exists for it today. The Gen. 6 above is
|
||||
// sourced well below list, so expect these to come DOWN once a sourcing
|
||||
// price lands: change the three numbers here (and the pins in
|
||||
// ShouldShipContent) and the €50 net margin holds by construction, as
|
||||
// the static_asserts above guarantee for every supplier price.
|
||||
//
|
||||
// Cobalt Blue leads the list: with one price across the range,
|
||||
// CheapestVariant() returns whichever colour comes first, and that is
|
||||
// the selector's pre-selected default — so it has to be the colour the
|
||||
// photo above actually shows.
|
||||
q.variants = {
|
||||
{ "blue", "Cobalt Blue", 64900 + kMarkupMinor },
|
||||
{ "green", "Forest Green", 64900 + kMarkupMinor },
|
||||
{ "black", "Horizon Black", 64900 + kMarkupMinor },
|
||||
};
|
||||
// The hardware, as Fairphone specifies it. Five rows differ from the
|
||||
// Gen. 6 — the panel, the SoC, the RAM, the charge rate, and Fairphone's
|
||||
// own rounding of the chassis length (156.57 mm here, 156.5 there, one
|
||||
// shell). The other eight are restated rather than cross-referenced: a
|
||||
// spec sheet that says "as above" is not a spec sheet.
|
||||
q.specs = {
|
||||
{ "Display", "6.31″ LTPO AMOLED, 2484 × 1116 (FHD+), 10–120 Hz adaptive, Gorilla Glass 7i" },
|
||||
{ "Processor", "Qualcomm Snapdragon 7s Gen 4, Adreno 810 GPU" },
|
||||
{ "Memory", "12 GB RAM" },
|
||||
{ "Storage", "256 GB, microSD slot up to 2 TB" },
|
||||
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
|
||||
{ "Front camera", "32 MP" },
|
||||
{ "Battery", "4415 mAh, user-replaceable, 27 W fast charging" },
|
||||
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
|
||||
{ "SIM", "Dual: nano-SIM + eSIM" },
|
||||
{ "USB", "USB-C 2.0" },
|
||||
{ "Fingerprint reader", "Side-mounted, in the power button" },
|
||||
{ "Durability", "IP55, 12 user-replaceable modules" },
|
||||
{ "Dimensions", "156.6 × 73.3 × 9.6 mm, 193 g" },
|
||||
};
|
||||
// Same derivation as above: the sticker is the cheapest colour, so the
|
||||
// page cannot advertise a number the default choice doesn't honour.
|
||||
if (const Variant* cheapest = q.CheapestVariant()) {
|
||||
q.priceInclMinor = cheapest->priceInclMinor;
|
||||
}
|
||||
|
||||
// The donation item: the shop's soft opening. It is "available" while
|
||||
// the phone above stays "coming-soon" on purpose — the two rails, the
|
||||
// the phones above stay "coming-soon" on purpose — the two rails, the
|
||||
// ledger, the reconciler and the mailer all run for real money before
|
||||
// the first phone order depends on them. The buyer names the amount
|
||||
// (Form::ValidateDonation bounds it); nothing ships and no VAT is
|
||||
// charged, because a gift with nothing supplied in return is not a
|
||||
// taxable supply — which is also why a donation never gets an invoice.
|
||||
// fp6-pmos stays FIRST in this vector: it is the shop's headline, and
|
||||
// tests address Products()[0] as the priced product.
|
||||
// The order of this vector is the order of the shop grid, and it is
|
||||
// deliberate: fp6-pmos FIRST (the shop's headline, and the tests
|
||||
// address Products()[0] as the priced product), the Gen. 6+ second,
|
||||
// and the donation LAST — it is the odd entry out, not what the shop
|
||||
// is about.
|
||||
Product d;
|
||||
d.slug = "donation";
|
||||
d.name = "Donation";
|
||||
|
|
@ -127,7 +212,7 @@ export const std::vector<Product>& Products() {
|
|||
d.status = "available";
|
||||
d.donation = true;
|
||||
d.summary = "Thank you very much for considering to donate! Your donation will contribute to the maintenance of the image and R&D for new devices.";
|
||||
return std::vector<Product>{ std::move(p), std::move(d) };
|
||||
return std::vector<Product>{ std::move(p), std::move(q), std::move(d) };
|
||||
}();
|
||||
return products;
|
||||
}
|
||||
|
|
@ -308,7 +393,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "imprint",
|
||||
.title = "Imprint & contact",
|
||||
.updated = "2026-08-05",
|
||||
.updated = "2026-08-19",
|
||||
.lede = "Who is behind this site and how to reach them.",
|
||||
.sections = {
|
||||
{ "Contact",
|
||||
|
|
@ -324,6 +409,15 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
"If you find a vulnerability, please email at security@catcrafts.net before disclosing it publicly and you will be credited. This site is source-available on Forgejo, so you can read exactly what it does rather than guessing.",
|
||||
} },
|
||||
// The CC BY-SA 4.0 licence on both product renders requires
|
||||
// naming the author, linking the licence and flagging that the
|
||||
// files were changed. project.cpp pointed here for it long
|
||||
// before anything here said it, which is the gap this closes.
|
||||
{ "Image credits",
|
||||
{
|
||||
"The product renders are the manufacturer's own: © Fairphone, used under the Creative Commons Attribution-ShareAlike 4.0 licence, https://creativecommons.org/licenses/by-sa/4.0 . The Fairphone (Gen. 6) in Forest Green came through Wikimedia Commons; the Fairphone (Gen. 6+) in Cobalt Blue comes from Fairphone's own photostream at https://www.flickr.com/photos/fairphone . Both were cropped and recompressed to fit this site, and those adaptations are offered back under the same licence.",
|
||||
"They are renders of stock hardware, which is exactly what Catcrafts ships — the difference is the software on it. No manufacturer render shows postmarketOS, so treat the screens in those images as the manufacturer's artwork rather than a picture of what arrives.",
|
||||
} },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -676,8 +676,8 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
|||
RenderedPage page;
|
||||
page.meta.title = "Shop — Catcrafts";
|
||||
page.meta.description =
|
||||
"Fairphone (Gen. 6) handsets reflashed to run postmarketOS, with the "
|
||||
"IMS/VoLTE stack Catcrafts maintains.";
|
||||
"Fairphone (Gen. 6) and (Gen. 6+) handsets reflashed to run postmarketOS, "
|
||||
"with the IMS/VoLTE stack Catcrafts maintains.";
|
||||
page.meta.canonical = "/shop";
|
||||
page.meta.geoPriceHint = true;
|
||||
// The category page as typed data: an ItemList naming each product page.
|
||||
|
|
@ -1325,16 +1325,23 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
|
||||
// The spec and warranty sections exist only where the content does: a
|
||||
// donation has neither a spec sheet nor a warranty, and an empty table
|
||||
// under a Fairphone-specific lede would be nonsense on its page.
|
||||
// under a manufacturer-specific lede would be nonsense on its page.
|
||||
//
|
||||
// The lede names the BRAND, never a model. It used to say "Fairphone
|
||||
// (Gen. 6)" in the markup, which read true while the catalogue held one
|
||||
// phone and went silently false the moment it held two — a spec sheet
|
||||
// introduced as some other device's sheet is worse than no lede at all.
|
||||
// A spec sheet with no brand to name is a content bug, asserted against in
|
||||
// ShouldShipContent rather than papered over with a second sentence here.
|
||||
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Specifications</h2>)"
|
||||
R"(<p class="section__lede">The hardware is a stock Fairphone )"
|
||||
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
|
||||
R"(<p class="section__lede">The hardware is a stock {}, unmodified. )"
|
||||
R"({}'s spec sheet is this product's spec sheet, )"
|
||||
R"(and all of it works under postmarketOS.</p>)"
|
||||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||||
R"(</section>)",
|
||||
Join(specRows));
|
||||
Escape(product.brand), Escape(product.brand), Join(specRows));
|
||||
const SafeHtml warrantySection = product.warranty.empty() ? SafeHtml{} : Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Warranty</h2>)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ cat <<EOF
|
|||
|
||||
/ home
|
||||
/shop product list
|
||||
/shop/fp6-pmos product page + checkout (fake payment rail)
|
||||
/shop/<slug> fp6-pmos, fp6plus-pmos, donation (fake payment rail)
|
||||
/projects the Crafter suite
|
||||
/posts fediverse posts
|
||||
/demos demo list; /demos/raytracer loads the wasm
|
||||
|
|
|
|||
Loading…
Reference in a new issue