feat: reposition desfoto as an independent brand with four Arbeitsbereiche

This commit is contained in:
desfoto automation
2026-09-19 19:14:42 +02:00
parent 96247da074
commit 68613788b8
86 changed files with 1287 additions and 756 deletions

View File

@@ -262,10 +262,11 @@ class Content(unittest.TestCase):
def test_all_requested_services_are_reachable(self):
home = read(SITE / "index.html")
for href in (
"/shootings/",
"/fotografie/",
"/businessfotografie/",
"/familien-und-paare/",
"/familie/",
"/minishootings/",
"/musik-und-buehne/",
"/projekte/",
"/video/",
"/social-media/",
@@ -315,31 +316,110 @@ class Content(unittest.TestCase):
class Redesign(unittest.TestCase):
"""The 2026 redesign: Shootings umbrella, real portfolio, logo, motion layer."""
"""The 2026 redesign: Arbeitsbereiche, real portfolio, logo, motion layer."""
def test_nav_uses_shootings_as_the_photo_umbrella(self):
def test_nav_uses_fotografie_as_the_photo_umbrella(self):
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
self.assertIn('href="/shootings/"', text)
self.assertNotIn('href="/fotografie/', text)
self.assertIn('href="/fotografie/"', text)
self.assertIn('href="/familie/"', text)
self.assertNotIn('href="/shootings', text)
self.assertNotIn('href="/familien-und-paare', text)
def test_all_four_shootings_are_grouped_under_the_umbrella(self):
shootings = read(SITE / "shootings" / "index.html")
def test_all_four_photo_schwerpunkte_are_grouped_under_the_umbrella(self):
fotografie = read(SITE / "fotografie" / "index.html")
for href in (
"/businessfotografie/",
"/portrait-und-model/",
"/familien-und-paare/",
"/familie/",
"/minishootings/",
):
with self.subTest(href=href):
self.assertIn(f'href="{href}"', shootings)
self.assertIn(f'href="{href}"', fotografie)
def test_photo_and_video_are_separate_but_both_on_the_homepage(self):
def test_homepage_explains_the_four_arbeitsbereiche(self):
home = read(SITE / "index.html")
self.assertEqual(home.count('<article class="pillar spot reveal">'), 2)
self.assertIn('<a class="pillar__go" href="/shootings/">Shootings ansehen', home)
self.assertIn('<a class="pillar__go" href="/video/">Videoprojekte ansehen', home)
start = home.index("Arbeitsbereiche")
end = home.index("</section>", start)
block = home[start:end]
self.assertEqual(4, block.count('<article class="card reveal">'))
for href in ("/fotografie/", "/video/", "/musik-und-buehne/", "/projekte/"):
with self.subTest(href=href):
self.assertIn(f'href="{href}"', block)
for scope in (
"Menschen, Unternehmen, Produkte, Veranstaltungen.",
"Unternehmen, Musik, Events, Social Content.",
"Bands, Künstler, Konzerte, Musikvideo.",
"Freie Arbeiten, Kooperationen, besondere Produktionen.",
):
with self.subTest(scope=scope):
self.assertIn(scope, block)
def test_homepage_separates_bookable_work_from_own_projects(self):
home = read(SITE / "index.html")
self.assertIn("Was du bei mir buchen kannst", home)
self.assertIn("Was ich außerdem mache", home)
self.assertLess(
home.index("Was du bei mir buchen kannst"),
home.index("Was ich außerdem mache"),
)
def test_couples_are_not_advertised_on_desfoto(self):
"""Paare/Engagement stay with dennyschulz.de; desfoto keeps single portraits."""
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
for needle in ("Paarshooting", "Paarportrait", "Verlobung", "Ja-Wort", "Brautpaar"):
self.assertNotIn(needle, text)
# Der einzige erlaubte Paar-Bezug ist der Hinweis auf den eigenen
# Schwerpunkt auf dennyschulz.de im Footer.
remainder = visible_text(text).replace("Hochzeiten und Paare", "")
self.assertNotIn("Paar", remainder)
def test_footer_carries_the_ownership_line_without_the_legal_entity(self):
"""The brand is tied to its owner in one unobtrusive footer sentence."""
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
self.assertIn("desfoto ist ein Angebot von Denny Schulz, Neumünster.", text)
footer = text[text.index("<footer"):]
self.assertNotIn("Denny Schulz Fotografie", footer)
def test_legal_entity_only_survives_in_legal_pages_and_structured_data(self):
"""„Denny Schulz Fotografie" ist die Rechtsperson, nicht die Marke.
Sichtbar darf sie nur auf Impressum und Datenschutz auftauchen; auf allen
anderen Seiten ist ausschließlich das unsichtbare ``legalName`` im
JSON-LD erlaubt.
"""
for file in HTML_FILES:
if file.parent.name in ("impressum", "datenschutz"):
continue
remainder = read(file).replace('"legalName":"Denny Schulz Fotografie"', "")
with self.subTest(file=str(file.relative_to(SITE))):
self.assertNotIn("Denny Schulz Fotografie", remainder)
def test_brand_is_written_lowercase_only(self):
"""Der Betreiber schreibt die Marke klein: ``desfoto``, nie ``DESFOTO``."""
for file in HTML_FILES:
with self.subTest(file=str(file.relative_to(SITE))):
self.assertNotIn("DESFOTO", read(file))
def test_built_pages_carry_no_trailing_whitespace(self):
"""Zeilen aus Leerzeichen lehnt das Release-Gateway (``git diff --check``) ab.
Eine bedingte Zeile in einer f-String-Vorlage erzeugt genau das: leer
eingesetzt bleibt die Einrückung stehen. Der Test hält die Ausgabe sauber,
damit die Freigabe nicht erst am Gateway scheitert.
"""
offenders = [
f"{file.relative_to(SITE)}:{number}"
for file in HTML_FILES
for number, line in enumerate(read(file).splitlines(), start=1)
if line != line.rstrip()
]
self.assertEqual([], offenders)
def test_links_are_never_nested(self):
"""Nested <a> is invalid and the parser splits the surrounding layout."""
@@ -370,30 +450,54 @@ class Redesign(unittest.TestCase):
self.assertEqual([], guard.problems)
self.assertEqual(0, guard.depth, "unbalanced <a>")
def test_pillar_grid_has_exactly_two_children(self):
"""Regression: the two pillars must stay two grid children."""
def test_every_area_card_links_to_its_area(self):
"""Each Arbeitsbereich card keeps its own link (no nested anchors)."""
home = read(SITE / "index.html")
start = home.index('<div class="pillars"')
start = home.index("Arbeitsbereiche")
end = home.index("</section>", start)
self.assertEqual(2, home[start:end].count('<article class="pillar spot reveal">'))
block = home[start:end]
self.assertEqual(4, block.count('class="link-arrow"'))
self.assertNotIn("<a class=\"card", block)
def test_nginx_redirects_the_retired_fotografie_url(self):
def test_nginx_redirects_the_renamed_photo_urls(self):
conf = read(ROOT / "nginx.conf")
for line in (
"location = /fotografie { return 301 /shootings/; }",
"location = /fotografie/ { return 301 /shootings/; }",
"location = /fotografie.html { return 301 /shootings/; }",
"location = /shootings { return 301 /fotografie/; }",
"location = /shootings/ { return 301 /fotografie/; }",
"location = /shootings.html { return 301 /fotografie/; }",
"location = /fotografie.html { return 301 /fotografie/; }",
"location = /familien-und-paare { return 301 /familie/; }",
"location = /familien-und-paare/ { return 301 /familie/; }",
"location = /familien-und-paare.html { return 301 /familie/; }",
):
with self.subTest(line=line):
self.assertIn(line, conf)
# The old umbrella must not win over the new one.
self.assertNotIn("return 301 /shootings/;", conf)
# /fotografie/index.html is canonicalised to /fotografie/ by the
# server-level index rule and reaches /shootings/ on the second hop.
# server-level index rule instead of bouncing back to itself.
self.assertIn(r"if ($request_uri ~ ^/(.*/)?index\.html(\?.*)?$)", conf)
def test_retired_fotografie_build_output_is_gone(self):
self.assertFalse((SITE / "fotografie").exists())
self.assertFalse((SITE / "assets" / "img" / "og-fotografie.jpg").exists())
def test_retired_routes_and_brand_bleed_assets_are_gone(self):
self.assertFalse((SITE / "shootings").exists())
self.assertFalse((SITE / "familien-und-paare").exists())
self.assertFalse((SITE / "assets" / "img" / "og-shootings.jpg").exists())
self.assertFalse((SITE / "assets" / "img" / "og-familien.jpg").exists())
manifest = json.loads(read(SRC / "images.json"))
slugs = {entry["slug"] for entry in manifest["images"]}
for slug in (
# Studiopanorama with a visible "Denny Schulz Fotograf" business card.
"studio-setup",
# Romantic couple imagery: advertised on dennyschulz.de only.
"people-joy",
"people-kiss",
"portrait-couple",
"couple-park",
"couple-backlight-kiss",
"couple-forest-walk",
"couple-sun-silhouette",
"couple-laugh-walk",
# Retired from an earlier build.
"people-forest",
"people-veil",
"people-walk",
@@ -405,8 +509,12 @@ class Redesign(unittest.TestCase):
"travel-norway-pano",
"travel-norway-valley",
):
for image in (SITE / "assets" / "img").glob(f"{slug}-*.webp"):
self.fail(f"stale image built: {image.name}")
with self.subTest(slug=slug):
self.assertNotIn(slug, slugs)
for image in (SITE / "assets" / "img").glob(f"{slug}-*.webp"):
self.fail(f"stale image built: {image.name}")
for file in HTML_FILES:
self.assertNotIn(slug, read(file))
def test_wedding_crowd_photos_are_not_shipped(self):
"""The operator asked to drop the wedding photos showing the guests."""
@@ -419,10 +527,29 @@ class Redesign(unittest.TestCase):
for file in HTML_FILES:
self.assertNotIn(slug, read(file))
def test_sitemap_lists_the_new_route_only(self):
def test_couple_photography_assets_are_gone_entirely(self):
"""Paare gehören zu dennyschulz.de — auch als Bild dürfen sie nicht mitlaufen.
``portrait-natural``, ``event-motion`` und ``event-dance`` zeigen Brautpaare
und sind deshalb aus dem Bildbestand entfernt, nicht nur von den Seiten.
"""
manifest = json.loads(read(SRC / "images.json"))
slugs = {entry["slug"] for entry in manifest["images"]}
for slug in ("portrait-natural", "event-motion", "event-dance"):
with self.subTest(slug=slug):
self.assertNotIn(slug, slugs)
self.assertEqual([], list((SITE / "assets" / "img").glob(f"{slug}-*.webp")))
for file in HTML_FILES:
self.assertNotIn(slug, read(file))
def test_sitemap_lists_the_new_routes_only(self):
sitemap = read(SITE / "sitemap.xml")
self.assertIn("<loc>https://desfoto.de/shootings/</loc>", sitemap)
self.assertNotIn("<loc>https://desfoto.de/fotografie/</loc>", sitemap)
for route in ("fotografie", "familie", "musik-und-buehne"):
with self.subTest(route=route):
self.assertIn(f"<loc>https://desfoto.de/{route}/</loc>", sitemap)
for retired in ("shootings", "familien-und-paare"):
with self.subTest(route=retired):
self.assertNotIn(f"<loc>https://desfoto.de/{retired}/</loc>", sitemap)
def test_operator_logo_is_the_brand_mark(self):
self.assertTrue((SITE / "assets" / "img" / "logo-160.png").is_file())
@@ -459,7 +586,7 @@ class Redesign(unittest.TestCase):
def test_mobile_studio_is_described_where_photo_work_is_sold(self):
needles = ("Mobiles Studio", "Autarke Blitzanlage", "autarker Blitzanlage")
for rel in ("index.html", "shootings/index.html", "businessfotografie/index.html"):
for rel in ("index.html", "fotografie/index.html", "businessfotografie/index.html"):
text = read(SITE / rel)
for needle in needles:
with self.subTest(file=rel, needle=needle):
@@ -469,7 +596,7 @@ class Redesign(unittest.TestCase):
"""Projects can also be photographed in the operator's own studio on site."""
for rel in (
"index.html",
"shootings/index.html",
"fotografie/index.html",
"businessfotografie/index.html",
"ueber/index.html",
):
@@ -489,13 +616,20 @@ class Redesign(unittest.TestCase):
"free-sparrow",
"free-dogs-forest",
),
"familien-und-paare": (
"couple-park",
"couple-backlight-kiss",
"couple-forest-walk",
"couple-sun-silhouette",
"couple-laugh-walk",
"familie": (
"free-lake-person",
"free-portrait-light",
"portrait-marina-snow",
"free-modern-portrait",
"free-woman-coast",
"portrait-marina-sunset",
),
"musik-und-buehne": (
"music-guitar-silhouette",
"band-extinct-guitar",
"music-neck",
"music-banner",
"music-drums",
),
"portrait-und-model": ("editorial-studio-side", "editorial-balance", "editorial-leap"),
"businessfotografie": (
@@ -522,6 +656,16 @@ class Redesign(unittest.TestCase):
with self.subTest(page="video", slug=slug):
self.assertIn(f"/assets/img/{slug}-1200.webp", video)
def test_musik_und_buehne_page_shows_the_real_cases(self):
page = read(SITE / "musik-und-buehne" / "index.html")
for needle in ("Thjódrörir", "Extinct", "Sagenbringer"):
with self.subTest(needle=needle):
self.assertIn(needle, page)
for step in ("Konzept", "Dreh", "Schnitt", "Farbkorrektur"):
with self.subTest(step=step):
self.assertIn(f"<h3>{step}</h3>", page)
self.assertIn("NWWFTf7l8g0", page)
def test_invented_japan_content_is_absent(self):
for file in HTML_FILES:
text = read(file).lower()
@@ -601,11 +745,17 @@ class Release(unittest.TestCase):
self.assertEqual(2, deploy.count("up -d --force-recreate --remove-orphans"))
self.assertIn("docker exec desfoto-web-1 wget -q --spider http://127.0.0.1/", deploy)
def test_verify_hook_covers_the_renamed_route(self):
def test_verify_hook_covers_the_renamed_routes(self):
verify = read(ROOT / ".ocauto" / "verify")
self.assertIn('check_redirect "$base/fotografie/" "$base/shootings/"', verify)
self.assertIn("""check_not_contains "$base/" 'href="/fotografie/'""", verify)
self.assertNotIn('/fotografie/ /businessfotografie/', verify)
for check in (
'check_redirect "$base/shootings/" "$base/fotografie/"',
'check_redirect "$base/familien-und-paare/" "$base/familie/"',
'check_contains "$base/fotografie/"',
'check_contains "$base/musik-und-buehne/"',
):
with self.subTest(check=check):
self.assertIn(check, verify)
self.assertNotIn("/shootings/ /businessfotografie/", verify)
self.assertNotIn("hero-studio-1200.webp", verify)
def test_deploy_hooks_are_executable(self):