mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
Merge pull request #303 from magnus919/feat/travel-guide-visual-system
feat(skill): travel-guide visual system — journey line, day strip, meters, photo grade
This commit is contained in:
@@ -6,7 +6,7 @@ Turn a destination, a real traveler, and a few constraints into a considered tra
|
||||
|
||||
Most itinerary tools optimize for coverage. This skill helps an agent design for fit: the pace, people, budget, interests, energy, and small details that make a trip feel like it belongs to the travelers.
|
||||
|
||||
It can produce a print-ready HTML dossier for PDF conversion, a responsive companion page, or both. It keeps current logistics and recommendations tied to sources, and it can create a sanitized edition for sharing without exposing exact dates, lodging, booking identifiers, or private notes.
|
||||
It can produce a print-ready HTML dossier for PDF conversion, a responsive companion page, or both. The visual system is editorial by default: a darkened photographic cover with a route journey line, ghost section numbers, a color-coded day strip, pace and budget meters, and a unified warm photo grade across anchor photos. It keeps current logistics and recommendations tied to sources, and it can create a sanitized edition for sharing without exposing exact dates, lodging, booking identifiers, or private notes.
|
||||
|
||||
## What You Get
|
||||
|
||||
|
||||
@@ -188,10 +188,12 @@ Adapt the length to the trip, but preserve the information hierarchy:
|
||||
7. skip this: attractive but poor-fit options, where useful;
|
||||
8. sources and freshness: links, retrieval dates, and unresolved uncertainty.
|
||||
|
||||
The visual default is a dark photographic cover, warm gold eyebrow, white
|
||||
headline, restrained red accent, generous white content pages, numbered
|
||||
sections, compact cards, and readable tables. Preserve contrast and selectable
|
||||
text. Do not let decoration hide uncertainty or practical caveats.
|
||||
The visual default is a dark photographic cover with a route journey line, warm
|
||||
gold eyebrow, white headline, restrained red accent, generous white content
|
||||
pages, ghost section numbers, a color-coded day strip right after the brief,
|
||||
pace and budget meters, compact cards, a unified warm photo grade on anchor
|
||||
images, and readable tables. Preserve contrast and selectable text. Do not let
|
||||
decoration hide uncertainty or practical caveats.
|
||||
|
||||
## Exit criteria
|
||||
|
||||
|
||||
@@ -79,16 +79,25 @@ surprises that depend on access, money, health, or another person's consent.
|
||||
The default visual language is editorial rather than app-like:
|
||||
|
||||
- a full-bleed, darkened cover photograph;
|
||||
- a route journey line on the cover (one dot per stop, dashed connector,
|
||||
night counts) when the route has two or more stops;
|
||||
- a warm gold eyebrow and restrained red accent;
|
||||
- large, left-aligned white title text;
|
||||
- white content pages with generous margins;
|
||||
- numbered sections and compact, scannable cards;
|
||||
- ghost section numbers and compact, scannable cards;
|
||||
- a color-coded day strip right after the brief, one card per day, with the
|
||||
day's kind (arrive, city, excursion, coast) driving the card color;
|
||||
- pace and budget meters in the brief when the trip model supplies them;
|
||||
- a unified warm photo grade on anchor images so mixed-source photos read as
|
||||
one editorial set;
|
||||
- dark table headers with clear column labels;
|
||||
- short captions and visible image credits.
|
||||
|
||||
Use images to establish place and texture, not to imply that an image proves a
|
||||
recommendation. Keep body text selectable and readable in grayscale or with
|
||||
high-contrast settings. Every meaningful image needs useful alternative text.
|
||||
The day strip is a glanceable overview, not a schedule: it must never invent a
|
||||
timed plan that the day cards do not support.
|
||||
|
||||
## Companion web page
|
||||
|
||||
|
||||
@@ -63,9 +63,14 @@ Before delivery, verify all of the following:
|
||||
- every major section begins on a fresh page;
|
||||
- text remains selectable;
|
||||
- the cover image and every intended local image are present;
|
||||
- the cover journey line renders when the route has two or more stops;
|
||||
- the day strip ("trip at a glance") shows one card per day with legible kind
|
||||
colors, and the meters render when pace or budget are supplied;
|
||||
- ghost section numbers do not collide with content;
|
||||
- no page is blank, clipped, or unexpectedly split;
|
||||
- title, tables, captions, and source URLs are readable;
|
||||
- contrast works on the dark cover and in grayscale content pages;
|
||||
- anchor photos carry the unified warm grade and remain legible;
|
||||
- page count is consistent with the requested scope;
|
||||
- links and document metadata are set when the renderer supports them;
|
||||
- the source JSON and renderer output are retained for regeneration.
|
||||
|
||||
@@ -81,6 +81,24 @@ Do not book or purchase anything. A booking link is a pointer, not evidence that
|
||||
availability exists. Mark availability as unverified unless the user or a
|
||||
booking tool has explicitly confirmed it.
|
||||
|
||||
## Photo sourcing
|
||||
|
||||
Photos are part of the visual contract: the cover and anchor cards take local
|
||||
images from the trip model, and the renderer embeds them into the artifact. The
|
||||
traveler's own photos are the best source. For anything else:
|
||||
|
||||
- prefer free-license sources (for example Wikimedia Commons with a CC0, CC BY,
|
||||
or CC BY-SA license) over scraped web images;
|
||||
- record the author and license in the image credit field so the rendered
|
||||
dossier can show it;
|
||||
- download the file into the working folder and reference it by relative path
|
||||
so the renderer embeds it; never hotlink an arbitrary web image into a
|
||||
private artifact;
|
||||
- give every image a descriptive alt attribute that says what the photo shows,
|
||||
not what it proves;
|
||||
- do not use a photo to imply that a recommendation is verified. A picture of a
|
||||
famous site is not a source for its opening hours.
|
||||
|
||||
## Research stop conditions
|
||||
|
||||
Stop and report a limitation when:
|
||||
|
||||
@@ -70,6 +70,84 @@ def render_mark():
|
||||
return ""
|
||||
|
||||
|
||||
def render_journey(trip):
|
||||
"""Cover route line: one dot per stop, dashed connector, night counts."""
|
||||
route = [r for r in trip.get("route", []) if isinstance(r, dict) and r.get("place")]
|
||||
if len(route) < 2:
|
||||
return ""
|
||||
pad, cy, top_y, label_y = 46.0, 40.0, 26.0, 64.0
|
||||
xs = [pad + (740.0 - 2 * pad) * i / (len(route) - 1) for i in range(len(route))]
|
||||
line = '<path d="%s" stroke="rgba(255,253,248,.5)" stroke-width="2.5" stroke-dasharray="1 9" stroke-linecap="round" fill="none"/>' % " L".join(
|
||||
"%.1f %.1f" % (x, cy) for x in xs
|
||||
)
|
||||
dots, labels, nights = [], [], []
|
||||
for i, (x, stop) in enumerate(zip(xs, route)):
|
||||
dots.append('<circle cx="%.1f" cy="%.1f" r="7" fill="%s" stroke="rgba(255,253,248,.85)" stroke-width="2"/>'
|
||||
% (x, cy, "#d8a929" if i else "#b51f39"))
|
||||
labels.append('<text x="%.1f" y="%.1f" text-anchor="middle" fill="rgba(255,253,248,.92)" font-size="15" font-weight="700" font-family="Arial, Helvetica, sans-serif">%s</text>'
|
||||
% (x, label_y, esc(stop.get("place"))))
|
||||
nights.append('<text x="%.1f" y="%.1f" text-anchor="middle" fill="rgba(216,169,41,.8)" font-size="11" font-family="Arial, Helvetica, sans-serif">%s</text>'
|
||||
% (x, top_y, ("%s nights" % stop["nights"]) if stop.get("nights") else "day trip"))
|
||||
places = ", ".join(str(s.get("place", "")) for s in route)
|
||||
return ('<svg class="journey" viewBox="0 0 740 82" role="img" aria-label="Route: %s">'
|
||||
'<title>Route: %s</title>%s%s%s%s</svg>'
|
||||
% (esc(places), esc(places), line, "".join(dots), "".join(labels), "".join(nights)))
|
||||
|
||||
|
||||
KIND_LABEL = {"arrive": "Arrival", "city": "City", "excursion": "Excursion", "coast": "Coast"}
|
||||
KIND_CLASS = {"arrive": "kind-arrive", "city": "kind-city", "excursion": "kind-excursion", "coast": "kind-coast"}
|
||||
|
||||
|
||||
def render_glance(brief):
|
||||
"""Day strip: one color-coded card per day, rendered right after the brief."""
|
||||
cards = []
|
||||
for day in brief.get("days", []):
|
||||
if not isinstance(day, dict):
|
||||
continue
|
||||
kind = str(day.get("kind", "")).strip().lower()
|
||||
cls = KIND_CLASS.get(kind, "kind-default")
|
||||
kind_label = KIND_LABEL.get(kind, "Day")
|
||||
label = esc(clean_text(day.get("label"), "Untitled day"))
|
||||
anchor = esc(clean_text(day.get("anchor"), "No anchor named"))
|
||||
cards.append('<div class="glance-day %s"><span class="glance-num">Day %s · %s</span><strong>%s</strong><small>%s</small></div>'
|
||||
% (cls, esc(day.get("day", "")), kind_label, label, anchor))
|
||||
if not cards:
|
||||
return ""
|
||||
has_kinds = any(isinstance(day, dict) and str(day.get("kind", "")).strip() for day in brief.get("days", []))
|
||||
legend = ('<p class="muted glance-note">Color marks the day\'s kind: arrival, city, excursion, coast. '
|
||||
'Days without a kind fall back to gold.</p>') if has_kinds else ""
|
||||
return ('<section class="sheet" id="glance">\n'
|
||||
' <p class="section-kicker">Trip at a glance</p>\n'
|
||||
' <h2>The whole trip, one glance.</h2>\n'
|
||||
' <div class="glance-grid">%s</div>\n%s\n</section>' % ("".join(cards), legend))
|
||||
|
||||
|
||||
PACE_LEVELS = {"slow": 2, "slow to moderate": 3, "moderate": 4, "moderate to high": 4, "high": 5, "fast": 5}
|
||||
|
||||
|
||||
def render_meters(brief):
|
||||
"""Segmented pace/budget meters in the brief; absent values render as text only."""
|
||||
trip = brief.get("trip", {})
|
||||
parts = []
|
||||
if isinstance(trip, dict):
|
||||
pace = clean_text(trip.get("pace"), "").lower()
|
||||
pace_level = PACE_LEVELS.get(pace)
|
||||
if pace_level is not None:
|
||||
cells = "".join('<span class="cell %s"></span>' % ("on" if i < pace_level else "off") for i in range(5))
|
||||
parts.append('<div class="meter"><span class="meter-label">Pace</span><div class="meter-cells" aria-hidden="true">%s</div><span class="meter-value">%s</span></div>'
|
||||
% (cells, esc(trip.get("pace", ""))))
|
||||
budget = trip.get("budget", {})
|
||||
amount = clean_text(budget.get("amount_range") or budget.get("label"), "") if isinstance(budget, dict) else ""
|
||||
euro_count = amount.count("€")
|
||||
if 1 <= euro_count <= 5:
|
||||
cells = "".join('<span class="cell %s"></span>' % ("on" if i < euro_count else "off") for i in range(5))
|
||||
parts.append('<div class="meter"><span class="meter-label">Budget</span><div class="meter-cells" aria-hidden="true">%s</div><span class="meter-value">%s</span></div>'
|
||||
% (cells, esc(amount)))
|
||||
if not parts:
|
||||
return ""
|
||||
return '<div class="trip-meters">%s</div>' % "".join(parts)
|
||||
|
||||
|
||||
def render_cover(brief, base_dir, warnings):
|
||||
trip = brief.get("trip", {})
|
||||
cover = brief.get("cover", {})
|
||||
@@ -89,6 +167,7 @@ def render_cover(brief, base_dir, warnings):
|
||||
credit = clean_text(cover.get("image", {}).get("credit")) if isinstance(cover.get("image", {}), dict) else ""
|
||||
credit_html = '<p class="image-credit">%s</p>' % esc(credit) if credit else ""
|
||||
region_html = " %s" % esc(region) if region else ""
|
||||
journey = render_journey(trip)
|
||||
return """
|
||||
<section class="cover" id="top">
|
||||
{image}
|
||||
@@ -104,6 +183,7 @@ def render_cover(brief, base_dir, warnings):
|
||||
<div class="cover-stat"><span class="cover-stat-label">Route</span><span class="cover-stat-value">{route_value}</span></div>
|
||||
<div class="cover-stat"><span class="cover-stat-label">Budget</span><span class="cover-stat-value">{budget_value}</span></div>
|
||||
</div>
|
||||
{journey}
|
||||
{credit_html}
|
||||
</div>
|
||||
</section>
|
||||
@@ -118,6 +198,7 @@ def render_cover(brief, base_dir, warnings):
|
||||
duration_value=esc(duration_value),
|
||||
route_value=esc(route_value),
|
||||
budget_value=esc(budget_value),
|
||||
journey=journey,
|
||||
credit_html=credit_html,
|
||||
)
|
||||
|
||||
@@ -137,15 +218,17 @@ def render_brief(brief):
|
||||
pace = clean_text(trip.get("pace"), "Not specified")
|
||||
traveler_count = len(trip.get("travelers", [])) if isinstance(trip.get("travelers"), list) else ""
|
||||
traveler_label = "%s traveler(s)" % traveler_count if traveler_count else "Travel party"
|
||||
meters = render_meters(brief)
|
||||
return """
|
||||
<section class="sheet page-break" id="brief">
|
||||
<p class="section-kicker">The brief</p>
|
||||
<h2>Why this trip, now?</h2>
|
||||
<p class="lede">{thesis}</p>
|
||||
<div class="route-grid">{route_html}</div>
|
||||
{meters}
|
||||
<div class="callout"><h3>Trip posture</h3><p>{pace} · {traveler_label} · The plan protects one meaningful experience at a time and leaves room for the place to interrupt it.</p></div>
|
||||
</section>
|
||||
""".format(thesis=esc(thesis), route_html=route_html, pace=esc(pace), traveler_label=esc(traveler_label))
|
||||
""".format(thesis=esc(thesis), route_html=route_html, meters=meters, pace=esc(pace), traveler_label=esc(traveler_label))
|
||||
|
||||
|
||||
def render_anchors(brief, base_dir, warnings):
|
||||
@@ -326,10 +409,10 @@ def render_sources(brief):
|
||||
|
||||
|
||||
def render_body(brief, base_dir, warnings, mode):
|
||||
body = [render_cover(brief, base_dir, warnings), render_brief(brief), render_anchors(brief, base_dir, warnings), render_days(brief), render_special(brief), render_skip(brief), render_practical(brief), render_sources(brief)]
|
||||
body = [render_cover(brief, base_dir, warnings), render_brief(brief), render_glance(brief), render_anchors(brief, base_dir, warnings), render_days(brief), render_special(brief), render_skip(brief), render_practical(brief), render_sources(brief)]
|
||||
nav = ""
|
||||
if mode == "companion":
|
||||
nav = '<nav class="companion-nav" aria-label="Guide sections"><a href="#brief">Brief</a><a href="#anchors">Anchors</a><a href="#days">Days</a><a href="#special">Special</a><a href="#practical">Field notes</a><a href="#sources">Sources</a></nav>'
|
||||
nav = '<nav class="companion-nav" aria-label="Guide sections"><a href="#brief">Brief</a><a href="#glance">At a glance</a><a href="#anchors">Anchors</a><a href="#days">Days</a><a href="#special">Special</a><a href="#practical">Field notes</a><a href="#sources">Sources</a></nav>'
|
||||
return nav + "\n".join(body)
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ def validate(data, strict=False):
|
||||
value = anchor.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append("%s.%s is required" % (prefix, key))
|
||||
image = anchor.get("image")
|
||||
if image is not None and not isinstance(image, dict):
|
||||
errors.append("%s.image must be an object" % prefix)
|
||||
if not isinstance(anchor.get("source_ids", []), list):
|
||||
errors.append("%s.source_ids must be an array" % prefix)
|
||||
elif strict and not anchor.get("source_ids"):
|
||||
@@ -125,6 +128,9 @@ def validate(data, strict=False):
|
||||
errors.append("%s.%s is required" % (prefix, key))
|
||||
if not isinstance(day.get("source_ids", []), list):
|
||||
errors.append("%s.source_ids must be an array" % prefix)
|
||||
kind = day.get("kind")
|
||||
if kind is not None and (not isinstance(kind, str) or kind.strip().lower() not in ("arrive", "city", "excursion", "coast")):
|
||||
warnings.append("%s.kind should be one of arrive, city, excursion, coast" % prefix)
|
||||
|
||||
sources = data.get("sources")
|
||||
if not isinstance(sources, list):
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
--gold: #d8a929;
|
||||
--signal: #b51f39;
|
||||
--orange: #ed8730;
|
||||
--teal: #1f7a7a;
|
||||
--white: #fffdf8;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--sans: Arial, Helvetica, sans-serif;
|
||||
@@ -49,6 +50,15 @@ img { max-width: 100%; height: auto; }
|
||||
.cover-stat-label { display: block; color: var(--gold); font-size: .66rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; }
|
||||
.cover-stat-value { display: block; margin-top: .2rem; font-size: 1.05rem; font-weight: 700; }
|
||||
.image-credit { margin: 2.5rem 0 0; color: rgba(255,255,255,.7); font-size: .68rem; }
|
||||
.journey { width: 100%; max-width: 34rem; margin: 1.6rem 0 0; }
|
||||
|
||||
.sheet { position: relative; isolation: isolate; counter-increment: sheet; }
|
||||
.sheet::before {
|
||||
content: counter(sheet, decimal-leading-zero);
|
||||
position: absolute; top: 2.6rem; right: 1.2rem; z-index: -1;
|
||||
font-size: 7.5rem; font-weight: 800; letter-spacing: -.05em; line-height: 1;
|
||||
color: rgba(23, 25, 27, .055); pointer-events: none;
|
||||
}
|
||||
|
||||
.sheet { max-width: 920px; margin: 0 auto; padding: 4.5rem 8vw; background: var(--paper); }
|
||||
.sheet + .sheet { border-top: 1px solid var(--line); }
|
||||
@@ -57,6 +67,7 @@ h2 { margin: 0 0 1.25rem; color: var(--ink); font-size: clamp(1.8rem, 4vw, 3.1re
|
||||
h3 { margin: 0; color: var(--ink); font-size: 1.15rem; line-height: 1.12; }
|
||||
p { line-height: 1.52; }
|
||||
.lede { max-width: 42rem; font-family: var(--serif); font-size: 1.3rem; line-height: 1.45; }
|
||||
.lede::first-letter { float: left; margin: .12em .12em 0 0; color: var(--signal); font-family: var(--serif); font-size: 3.6em; line-height: .8; font-weight: 700; }
|
||||
.muted { color: var(--muted); }
|
||||
.rule { height: 1px; margin: 2rem 0; background: var(--line); border: 0; }
|
||||
|
||||
@@ -65,10 +76,27 @@ p { line-height: 1.52; }
|
||||
.route-card strong, .practical-card strong { display: block; margin-bottom: .35rem; }
|
||||
.route-card small, .practical-card small { color: var(--muted); }
|
||||
|
||||
.trip-meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1.6rem; margin: 1.6rem 0 .4rem; }
|
||||
.meter-label { display: block; color: var(--signal); font-size: .64rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.meter-cells { display: flex; gap: 3px; margin: .45rem 0 .3rem; }
|
||||
.meter-cells .cell { width: 1.15rem; height: .5rem; background: var(--line); }
|
||||
.meter-cells .cell.on { background: var(--gold); }
|
||||
.meter-value { color: var(--muted); font-size: .82rem; }
|
||||
|
||||
.glance-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: .6rem; margin: 1.4rem 0 .6rem; }
|
||||
.glance-day { padding: .85rem .95rem .9rem; background: var(--white); border-top: 4px solid var(--gold); break-inside: avoid; }
|
||||
.glance-day.kind-city { border-top-color: var(--signal); }
|
||||
.glance-day.kind-excursion { border-top-color: var(--orange); }
|
||||
.glance-day.kind-coast { border-top-color: var(--teal); }
|
||||
.glance-num { display: block; color: var(--muted); font-size: .64rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.glance-day strong { display: block; margin: .3rem 0 .25rem; font-size: .95rem; line-height: 1.25; }
|
||||
.glance-day small { display: block; color: var(--muted); font-size: .78rem; line-height: 1.35; }
|
||||
.glance-note { margin: .8rem 0 0; font-size: .78rem; }
|
||||
|
||||
.anchor-list { display: grid; gap: 1.3rem; }
|
||||
.anchor-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.1rem; padding: 1.25rem 0; border-top: 1px solid var(--line); break-inside: avoid; }
|
||||
.anchor-card.has-image { grid-template-columns: 11rem minmax(0, 1fr); }
|
||||
.anchor-image { width: 100%; height: 8.5rem; border-radius: .25rem; object-fit: cover; background: #d3d0c7; }
|
||||
.anchor-image { width: 100%; height: 8.5rem; border-radius: .25rem; object-fit: cover; background: #d3d0c7; filter: sepia(.34) saturate(.72) contrast(1.08) brightness(.94); }
|
||||
.number { display: inline-grid; width: 1.8rem; height: 1.8rem; margin-right: .45rem; place-items: center; border-radius: 50%; background: var(--signal); color: var(--white); font-weight: 800; }
|
||||
.anchor-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: .65rem; margin-top: 1rem; font-size: .88rem; }
|
||||
.anchor-meta dt { color: var(--signal); font-size: .64rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
|
||||
@@ -32,6 +32,7 @@ moment.]
|
||||
### [Anchor title]
|
||||
|
||||
- Place: [specific venue, route, event, or neighborhood]
|
||||
- Image: [optional local file path, alt text, and credit]
|
||||
- Why it fits: [concrete mechanism of fit]
|
||||
- Best window: [time/day/season]
|
||||
- Cost: [range, currency, and assumptions]
|
||||
@@ -43,6 +44,8 @@ moment.]
|
||||
|
||||
### Day [number] — [label]
|
||||
|
||||
- Kind: [optional: arrive, city, excursion, or coast — drives the color of the
|
||||
day strip on the "trip at a glance" page]
|
||||
- Anchor: [one thing worth protecting]
|
||||
- Texture: [meal, market, street, shop, or ordinary-life detail]
|
||||
- Pause: [recovery space or empty time]
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
{
|
||||
"title": "A quiet first look at the city",
|
||||
"place": "Replace with a specific viewpoint, museum, or neighborhood route",
|
||||
"image": {"src": "", "alt": "", "credit": ""},
|
||||
"why": "It gives the travelers orientation without spending the first day on a checklist.",
|
||||
"best_window": "Late morning",
|
||||
"cost": "Add a sourced range",
|
||||
@@ -55,6 +56,7 @@
|
||||
{
|
||||
"title": "A meal worth making the evening about",
|
||||
"place": "Replace with a specific restaurant or market stall",
|
||||
"image": {"src": "", "alt": "", "credit": ""},
|
||||
"why": "The meal is the evening's anchor, so the rest of the day can stay deliberately light.",
|
||||
"best_window": "Early dinner or the first available counter seating",
|
||||
"cost": "Add a sourced range and currency",
|
||||
@@ -65,6 +67,7 @@
|
||||
{
|
||||
"title": "An ordinary-life texture",
|
||||
"place": "Replace with a specific market, shop, tram route, or neighborhood walk",
|
||||
"image": {"src": "", "alt": "", "credit": ""},
|
||||
"why": "It makes the trip about how the city feels between headline attractions.",
|
||||
"best_window": "A weekday morning",
|
||||
"cost": "Free or add a sourced estimate",
|
||||
@@ -77,6 +80,7 @@
|
||||
{
|
||||
"day": 1,
|
||||
"label": "Arrive without proving anything",
|
||||
"kind": "arrive",
|
||||
"anchor": "A short orientation walk near the base",
|
||||
"texture": "A first meal chosen for ease, not prestige",
|
||||
"pause": "Leave the afternoon unassigned",
|
||||
@@ -87,6 +91,7 @@
|
||||
{
|
||||
"day": 2,
|
||||
"label": "One substantial thing, then drift",
|
||||
"kind": "city",
|
||||
"anchor": "Use the first cultural anchor",
|
||||
"texture": "A named street, market, or small shop nearby",
|
||||
"pause": "Return before the second major commitment",
|
||||
|
||||
@@ -91,6 +91,93 @@ class TravelGuideScriptsTest(unittest.TestCase):
|
||||
self.assertEqual(render.returncode, 0, render.stderr)
|
||||
self.assertIn('data-privacy-mode="shareable"', html_output.read_text(encoding="utf-8"))
|
||||
|
||||
def _filled_brief(self):
|
||||
data = json.loads(TEMPLATE.read_text(encoding="utf-8"))
|
||||
data["trip"]["route"] = [{"place": "Lisbon", "nights": 3}, {"place": "Sintra", "nights": 1}]
|
||||
data["thesis"] = ("A week in Lisbon with enough structure to protect the good parts of the day "
|
||||
"and enough slack for the city to interrupt the plan.")
|
||||
data["anchors"] = [{
|
||||
"title": "A quiet first look",
|
||||
"place": "Miradouro da Graça",
|
||||
"why": "It gives the travelers orientation without a checklist.",
|
||||
"best_window": "Late morning",
|
||||
"cost": "Free",
|
||||
"booking": "No booking",
|
||||
"failure_mode": "Rain sends the walk indoors.",
|
||||
"source_ids": ["S1"],
|
||||
}]
|
||||
data["days"] = [{
|
||||
"day": 1,
|
||||
"label": "Arrive without proving anything",
|
||||
"kind": "arrive",
|
||||
"anchor": "A short orientation walk",
|
||||
"texture": "A first meal chosen for ease",
|
||||
"pause": "Leave the afternoon unassigned",
|
||||
"alternative": "A nearby café and an early night.",
|
||||
"practical": "Transfer and check-in caveat.",
|
||||
"source_ids": ["S4"],
|
||||
}]
|
||||
return data
|
||||
|
||||
def test_renderer_adds_journey_line_for_multi_stop_route(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
brief = directory / "brief.json"
|
||||
brief.write_text(json.dumps(self._filled_brief()), encoding="utf-8")
|
||||
output = directory / "dossier.html"
|
||||
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
rendered = output.read_text(encoding="utf-8")
|
||||
self.assertIn('class="journey"', rendered)
|
||||
self.assertIn("Sintra", rendered)
|
||||
self.assertIn("3 nights", rendered)
|
||||
|
||||
def test_renderer_adds_glance_strip_and_meters(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
brief = directory / "brief.json"
|
||||
brief.write_text(json.dumps(self._filled_brief()), encoding="utf-8")
|
||||
output = directory / "dossier.html"
|
||||
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
rendered = output.read_text(encoding="utf-8")
|
||||
self.assertIn('id="glance"', rendered)
|
||||
self.assertIn('class="glance-day kind-arrive"', rendered)
|
||||
self.assertIn('class="trip-meters"', rendered)
|
||||
self.assertIn("Slow to moderate", rendered)
|
||||
|
||||
def test_renderer_skips_journey_line_for_single_stop_route(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
brief = directory / "brief.json"
|
||||
data = self._filled_brief()
|
||||
data["trip"]["route"] = [{"place": "Lisbon", "nights": 7}]
|
||||
brief.write_text(json.dumps(data), encoding="utf-8")
|
||||
output = directory / "dossier.html"
|
||||
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
rendered = output.read_text(encoding="utf-8")
|
||||
self.assertNotIn('class="journey"', rendered)
|
||||
|
||||
def test_visual_system_css_is_present(self):
|
||||
css = (ROOT / "styles" / "travel-dossier.css").read_text(encoding="utf-8")
|
||||
self.assertIn("counter-increment: sheet", css)
|
||||
self.assertIn(".glance-grid", css)
|
||||
self.assertIn(".trip-meters", css)
|
||||
self.assertIn("filter: sepia", css)
|
||||
|
||||
def test_validator_warns_on_unknown_kind(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
brief = directory / "brief.json"
|
||||
data = self._filled_brief()
|
||||
data["days"][0]["kind"] = "mountain"
|
||||
brief.write_text(json.dumps(data), encoding="utf-8")
|
||||
result = run_script("validate-trip-brief.py", brief, "--json")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertTrue(any("kind" in warning for warning in payload["warnings"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user