isaachwf commited on
Commit
4fb118c
·
verified ·
1 Parent(s): d79b6e6

Sync from IsaacHuo/maptoposter via hub-sync

Browse files
AGENTS.md CHANGED
@@ -54,7 +54,7 @@ uv run pytest test_logic.py -k "china_hierarchy or manual_coordinates"
54
  uv run python verify_data.py
55
 
56
  # Create map poster directly (CLI)
57
- uv run python create_map_poster.py --city "Beijing" --theme japanese_ink --output-format png
58
  ```
59
 
60
  ## Code Style Guidelines
 
54
  uv run python verify_data.py
55
 
56
  # Create map poster directly (CLI)
57
+ uv run python create_map_poster.py --city "Beijing" --country "China" --theme japanese_ink --output-format png
58
  ```
59
 
60
  ## Code Style Guidelines
backend/main.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
  import os
6
  from dataclasses import asdict
7
  from pathlib import Path
 
8
 
9
  from fastapi import FastAPI, HTTPException, Query
10
  from fastapi.middleware.cors import CORSMiddleware
@@ -12,7 +13,7 @@ from fastapi.responses import Response
12
  from fastapi.staticfiles import StaticFiles
13
 
14
  from maptoposter import __version__
15
- from maptoposter.export import generate_output_filename
16
  from maptoposter.geocoding import GeocodingError
17
  from maptoposter.layouts import LAYOUTS
18
  from maptoposter.map_data import MapDataError
@@ -82,6 +83,20 @@ def preview(request: PreviewRequest) -> Response:
82
  raise HTTPException(status_code=404 if isinstance(exc, MapDataError) else 400, detail=str(exc)) from exc
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  @app.post("/api/v1/posters/export")
86
  def export(request: ExportRequest) -> Response:
87
  try:
@@ -104,7 +119,7 @@ def export(request: ExportRequest) -> Response:
104
  return Response(
105
  content,
106
  media_type={"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"}[request.format],
107
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
108
  )
109
  except (ValueError, MapDataError) as exc:
110
  raise HTTPException(status_code=404 if isinstance(exc, MapDataError) else 400, detail=str(exc)) from exc
 
5
  import os
6
  from dataclasses import asdict
7
  from pathlib import Path
8
+ from urllib.parse import quote
9
 
10
  from fastapi import FastAPI, HTTPException, Query
11
  from fastapi.middleware.cors import CORSMiddleware
 
13
  from fastapi.staticfiles import StaticFiles
14
 
15
  from maptoposter import __version__
16
+ from maptoposter.export import ascii_download_filename, generate_output_filename
17
  from maptoposter.geocoding import GeocodingError
18
  from maptoposter.layouts import LAYOUTS
19
  from maptoposter.map_data import MapDataError
 
83
  raise HTTPException(status_code=404 if isinstance(exc, MapDataError) else 400, detail=str(exc)) from exc
84
 
85
 
86
+ def content_disposition(filename: str) -> str:
87
+ """Build an RFC 6266 Content-Disposition value that is Latin-1 safe.
88
+
89
+ An ASCII ``filename=`` fallback is always sent; when the readable name
90
+ contains non-ASCII characters, an RFC 5987 ``filename*=UTF-8''...`` with
91
+ percent-encoding is appended so clients keep the original name.
92
+ """
93
+ ascii_filename = ascii_download_filename(filename)
94
+ disposition = f'attachment; filename="{ascii_filename}"'
95
+ if ascii_filename != filename:
96
+ disposition += f"; filename*=UTF-8''{quote(filename, safe='')}"
97
+ return disposition
98
+
99
+
100
  @app.post("/api/v1/posters/export")
101
  def export(request: ExportRequest) -> Response:
102
  try:
 
119
  return Response(
120
  content,
121
  media_type={"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"}[request.format],
122
+ headers={"Content-Disposition": content_disposition(filename)},
123
  )
124
  except (ValueError, MapDataError) as exc:
125
  raise HTTPException(status_code=404 if isinstance(exc, MapDataError) else 400, detail=str(exc)) from exc
create_map_poster.py CHANGED
@@ -133,8 +133,8 @@ def list_themes():
133
  print()
134
 
135
 
136
- def main():
137
- """Run the command-line poster generator."""
138
  parser = argparse.ArgumentParser(
139
  description="Generate beautiful map posters for any city",
140
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -186,11 +186,19 @@ Examples:
186
  parser.add_argument(
187
  "--format",
188
  "-f",
 
 
189
  default="png",
190
  choices=["png", "svg", "pdf"],
191
  help="Output format for the poster (default: png)",
192
  )
193
 
 
 
 
 
 
 
194
  args = parser.parse_args()
195
 
196
  if len(os.sys.argv) == 1:
 
133
  print()
134
 
135
 
136
+ def build_parser() -> argparse.ArgumentParser:
137
+ """Build the argument parser for the legacy command-line entry point."""
138
  parser = argparse.ArgumentParser(
139
  description="Generate beautiful map posters for any city",
140
  formatter_class=argparse.RawDescriptionHelpFormatter,
 
186
  parser.add_argument(
187
  "--format",
188
  "-f",
189
+ "--output-format",
190
+ dest="format",
191
  default="png",
192
  choices=["png", "svg", "pdf"],
193
  help="Output format for the poster (default: png)",
194
  )
195
 
196
+ return parser
197
+
198
+
199
+ def main():
200
+ """Run the command-line poster generator."""
201
+ parser = build_parser()
202
  args = parser.parse_args()
203
 
204
  if len(os.sys.argv) == 1:
frontend/src/api.test.ts CHANGED
@@ -1,6 +1,19 @@
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
  import type { PosterRequest } from './types'
3
- import { prepareMap } from './api'
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  describe('API network errors', () => {
6
  afterEach(() => vi.unstubAllGlobals())
@@ -13,3 +26,61 @@ describe('API network errors', () => {
13
  )
14
  })
15
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
  import type { PosterRequest } from './types'
3
+ import { exportPoster, prepareMap, searchPlaces } from './api'
4
+
5
+ function jsonResponse(payload: unknown): Response {
6
+ return { ok: true, json: async () => payload } as unknown as Response
7
+ }
8
+
9
+ function exportResponse(disposition: string): Response {
10
+ return {
11
+ ok: true,
12
+ status: 200,
13
+ headers: { get: (name: string) => (name.toLowerCase() === 'content-disposition' ? disposition : null) },
14
+ blob: async () => new Blob(['poster']),
15
+ } as unknown as Response
16
+ }
17
 
18
  describe('API network errors', () => {
19
  afterEach(() => vi.unstubAllGlobals())
 
26
  )
27
  })
28
  })
29
+
30
+ describe('place search language', () => {
31
+ afterEach(() => vi.unstubAllGlobals())
32
+
33
+ it.each(['zh', 'zh-CN', 'zh-Hans'])('requests lang=cn for the %s locale', async (language) => {
34
+ vi.stubGlobal('navigator', { language })
35
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]))
36
+ vi.stubGlobal('fetch', fetchMock)
37
+
38
+ await searchPlaces('Tokyo')
39
+
40
+ const url = String(fetchMock.mock.calls[0][0])
41
+ expect(url).toContain('lang=cn')
42
+ expect(url).not.toContain('lang=zh')
43
+ })
44
+
45
+ it('requests lang=en for non-Chinese locales', async () => {
46
+ vi.stubGlobal('navigator', { language: 'en-US' })
47
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]))
48
+ vi.stubGlobal('fetch', fetchMock)
49
+
50
+ await searchPlaces('Tokyo')
51
+
52
+ expect(String(fetchMock.mock.calls[0][0])).toContain('lang=en')
53
+ })
54
+ })
55
+
56
+ describe('export download filename', () => {
57
+ afterEach(() => vi.unstubAllGlobals())
58
+
59
+ it('prefers the RFC 5987 filename* for Chinese names', async () => {
60
+ const disposition =
61
+ 'attachment; filename="japanese-ink-20261017.png"; ' +
62
+ "filename*=UTF-8''%E5%8C%97%E4%BA%AC%E5%B8%82-japanese-ink-20261017.png"
63
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(exportResponse(disposition)))
64
+
65
+ const { filename } = await exportPoster('a'.repeat(64), {} as PosterRequest, 'png', 300)
66
+
67
+ expect(filename).toBe('北京市-japanese-ink-20261017.png')
68
+ })
69
+
70
+ it('falls back to the ASCII filename when filename* is absent', async () => {
71
+ const disposition = 'attachment; filename="beijing-japanese-ink-20261017.png"'
72
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(exportResponse(disposition)))
73
+
74
+ const { filename } = await exportPoster('a'.repeat(64), {} as PosterRequest, 'png', 300)
75
+
76
+ expect(filename).toBe('beijing-japanese-ink-20261017.png')
77
+ })
78
+
79
+ it('uses the format-based default when the header is missing', async () => {
80
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(exportResponse('')))
81
+
82
+ const { filename } = await exportPoster('a'.repeat(64), {} as PosterRequest, 'pdf', 300)
83
+
84
+ expect(filename).toBe('maptoposter.pdf')
85
+ })
86
+ })
frontend/src/api.ts CHANGED
@@ -25,12 +25,32 @@ async function expectJson<T>(response: Response): Promise<T> {
25
  return response.json() as Promise<T>
26
  }
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  export async function getStyles(signal?: AbortSignal): Promise<StylePreset[]> {
29
  return expectJson(await request(`${API_ROOT}/styles`, { signal }, 'Could not reach the MapToPoster server. Please retry.'))
30
  }
31
 
32
  export async function searchPlaces(query: string, signal?: AbortSignal): Promise<LocationResult[]> {
33
- const params = new URLSearchParams({ q: query, lang: navigator.language.startsWith('zh') ? 'zh' : 'en' })
34
  return expectJson(await request(`${API_ROOT}/locations/search?${params}`, { signal }, 'Place search was interrupted. Please retry.'))
35
  }
36
 
@@ -63,6 +83,6 @@ export async function exportPoster(mapDataId: string, poster: PosterRequest, for
63
  }, `The ${format.toUpperCase()} export connection was interrupted. Please retry.`)
64
  if (!response.ok) throw new Error(await readError(response))
65
  const disposition = response.headers.get('Content-Disposition') ?? ''
66
- const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] ?? `maptoposter.${format}`
67
  return { blob: await response.blob(), filename }
68
  }
 
25
  return response.json() as Promise<T>
26
  }
27
 
28
+ function decodeRfc5987(value: string): string | null {
29
+ const match = /^(?:UTF-8|utf-8)''(.+)$/.exec(value.trim().replace(/^"|"$/g, ''))
30
+ if (!match) return null
31
+ try {
32
+ return decodeURIComponent(match[1])
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ function parseDownloadFilename(disposition: string, fallback: string): string {
39
+ const extended = /filename\*\s*=\s*([^;]+)/i.exec(disposition)
40
+ if (extended) {
41
+ const decoded = decodeRfc5987(extended[1])
42
+ if (decoded) return decoded
43
+ }
44
+ const basic = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition)
45
+ return basic?.[1] ?? fallback
46
+ }
47
+
48
  export async function getStyles(signal?: AbortSignal): Promise<StylePreset[]> {
49
  return expectJson(await request(`${API_ROOT}/styles`, { signal }, 'Could not reach the MapToPoster server. Please retry.'))
50
  }
51
 
52
  export async function searchPlaces(query: string, signal?: AbortSignal): Promise<LocationResult[]> {
53
+ const params = new URLSearchParams({ q: query, lang: navigator.language.startsWith('zh') ? 'cn' : 'en' })
54
  return expectJson(await request(`${API_ROOT}/locations/search?${params}`, { signal }, 'Place search was interrupted. Please retry.'))
55
  }
56
 
 
83
  }, `The ${format.toUpperCase()} export connection was interrupted. Please retry.`)
84
  if (!response.ok) throw new Error(await readError(response))
85
  const disposition = response.headers.get('Content-Disposition') ?? ''
86
+ const filename = parseDownloadFilename(disposition, `maptoposter.${format}`)
87
  return { blob: await response.blob(), filename }
88
  }
src/maptoposter/cli.py CHANGED
@@ -29,7 +29,14 @@ def build_parser() -> argparse.ArgumentParser:
29
  parser.add_argument("--country", "-C", default="")
30
  parser.add_argument("--theme", "-t", default="feature_based", choices=list_themes())
31
  parser.add_argument("--distance", "-d", type=float, default=10_000)
32
- parser.add_argument("--format", "-f", choices=[item.value for item in OutputFormat], default="png")
 
 
 
 
 
 
 
33
  parser.add_argument("--layout", choices=[item.value for item in LayoutPreset], default="classic")
34
  parser.add_argument("--list-themes", action="store_true")
35
  parser.add_argument("--output", type=Path)
 
29
  parser.add_argument("--country", "-C", default="")
30
  parser.add_argument("--theme", "-t", default="feature_based", choices=list_themes())
31
  parser.add_argument("--distance", "-d", type=float, default=10_000)
32
+ parser.add_argument(
33
+ "--format",
34
+ "-f",
35
+ "--output-format",
36
+ dest="format",
37
+ choices=[item.value for item in OutputFormat],
38
+ default="png",
39
+ )
40
  parser.add_argument("--layout", choices=[item.value for item in LayoutPreset], default="classic")
41
  parser.add_argument("--list-themes", action="store_true")
42
  parser.add_argument("--output", type=Path)
src/maptoposter/export.py CHANGED
@@ -7,7 +7,17 @@ from pathlib import Path
7
 
8
  from .models import OutputFormat
9
  from .paths import POSTERS_DIR
10
- from .typography import safe_slug
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  def generate_output_filename(
 
7
 
8
  from .models import OutputFormat
9
  from .paths import POSTERS_DIR
10
+ from .typography import safe_ascii_slug, safe_slug
11
+
12
+
13
+ def ascii_download_filename(filename: str, fallback: str = "map-poster") -> str:
14
+ """Return an ASCII-only filename that preserves the original extension.
15
+
16
+ HTTP header values must be Latin-1 encodable; non-ASCII poster names are
17
+ reduced to their ASCII parts for the ``filename=`` fallback.
18
+ """
19
+ path = Path(filename)
20
+ return f"{safe_ascii_slug(path.stem, fallback=fallback)}{path.suffix}"
21
 
22
 
23
  def generate_output_filename(
src/maptoposter/geocoding.py CHANGED
@@ -18,6 +18,17 @@ from .models import Coordinate, Location
18
 
19
  COORDINATE_QUERY = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*[,/ ]\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$")
20
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  class GeocodingError(RuntimeError):
23
  """Raised when a place search cannot be completed."""
@@ -65,7 +76,7 @@ class NominatimProvider:
65
  exactly_one=False,
66
  limit=limit,
67
  addressdetails=True,
68
- language="zh-CN" if language == "cn" else "en",
69
  )
70
  except Exception as exc:
71
  raise GeocodingError(f"Location service is unavailable: {exc}") from exc
 
18
 
19
  COORDINATE_QUERY = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*[,/ ]\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$")
20
 
21
+ CHINESE_LANGUAGE_CODES = frozenset({"cn", "zh", "zh-cn"})
22
+
23
+
24
+ def nominatim_language(language: str) -> str:
25
+ """Map a UI language code to the language sent to Nominatim.
26
+
27
+ The product documents ``en`` and ``cn``; ``zh``/``zh-CN`` are accepted as
28
+ aliases for Chinese so clients that already send them still localize.
29
+ """
30
+ return "zh-CN" if language.strip().lower() in CHINESE_LANGUAGE_CODES else "en"
31
+
32
 
33
  class GeocodingError(RuntimeError):
34
  """Raised when a place search cannot be completed."""
 
76
  exactly_one=False,
77
  limit=limit,
78
  addressdetails=True,
79
+ language=nominatim_language(language),
80
  )
81
  except Exception as exc:
82
  raise GeocodingError(f"Location service is unavailable: {exc}") from exc
src/maptoposter/typography.py CHANGED
@@ -116,6 +116,19 @@ def safe_slug(value: str, fallback: str = "map-poster") -> str:
116
  return value[:80] or fallback
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def fitted_font_size(text: str, requested: float, max_units: float, minimum: float = 18) -> float:
120
  """Return a deterministic first-pass title size for a bounded layout region."""
121
  if not text:
 
116
  return value[:80] or fallback
117
 
118
 
119
+ def safe_ascii_slug(value: str, fallback: str = "map-poster") -> str:
120
+ """Create an ASCII-only filename slug by dropping non-ASCII characters.
121
+
122
+ Used to build the Latin-1-safe ``filename=`` fallback of an HTTP
123
+ ``Content-Disposition`` header, while ``safe_slug`` keeps the readable
124
+ Unicode form transported through RFC 5987 ``filename*``.
125
+ """
126
+ value = value.replace("_", "-")
127
+ value = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower())
128
+ value = re.sub(r"-+", "-", value).strip("-_")
129
+ return value[:80] or fallback
130
+
131
+
132
  def fitted_font_size(text: str, requested: float, max_units: float, minimum: float = 18) -> float:
133
  """Return a deterministic first-pass title size for a bounded layout region."""
134
  if not text:
tests/test_api.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
 
4
 
5
  from fastapi.testclient import TestClient
6
 
@@ -54,6 +55,55 @@ def test_prepare_preview_and_export_contract(monkeypatch, tmp_path) -> None:
54
  assert "beijing-japanese-ink" in exported.headers["content-disposition"]
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def test_prepare_keeps_selected_bbox_for_portrait_ratio(monkeypatch, tmp_path) -> None:
58
  key = "b" * 64
59
  captured = {}
 
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
4
+ from urllib.parse import unquote
5
 
6
  from fastapi.testclient import TestClient
7
 
 
55
  assert "beijing-japanese-ink" in exported.headers["content-disposition"]
56
 
57
 
58
+ def test_export_chinese_title_returns_valid_content_disposition(monkeypatch, tmp_path) -> None:
59
+ key = "c" * 64
60
+ monkeypatch.setattr(service, "export_poster", lambda reference, config, export: b"%PDF-test")
61
+
62
+ payload = poster_payload()
63
+ payload["typography"] = {**payload["typography"], "title": "北京市"}
64
+ exported = client.post(
65
+ "/api/v1/posters/export",
66
+ json={"map_data_id": key, "poster": payload, "format": "pdf", "dpi": 300},
67
+ )
68
+
69
+ assert exported.status_code == 200
70
+ assert exported.headers["content-type"] == "application/pdf"
71
+ assert exported.content
72
+
73
+ disposition = exported.headers["content-disposition"]
74
+ basic = disposition.split("filename=")[1].split(";")[0].strip().strip('"')
75
+ assert basic, "ASCII fallback filename must be present"
76
+ assert basic.isascii(), f"fallback filename must be ASCII: {basic!r}"
77
+ assert "japanese-ink" in basic
78
+
79
+ assert "filename*=UTF-8''" in disposition
80
+ encoded = disposition.split("filename*=UTF-8''", 1)[1]
81
+ decoded = unquote(encoded)
82
+ assert decoded.startswith("北京市-japanese-ink-")
83
+ assert decoded.endswith(".pdf")
84
+
85
+
86
+ def test_export_uses_chinese_display_name_when_title_blank(monkeypatch, tmp_path) -> None:
87
+ key = "d" * 64
88
+ monkeypatch.setattr(service, "export_poster", lambda reference, config, export: b"\x89PNG\r\n")
89
+
90
+ payload = poster_payload()
91
+ payload["typography"] = {**payload["typography"], "title": ""}
92
+ payload["location"] = {"display_name": "北京市", "latitude": 39.9042, "longitude": 116.4074, "country": "China"}
93
+ exported = client.post(
94
+ "/api/v1/posters/export",
95
+ json={"map_data_id": key, "poster": payload, "format": "png", "dpi": 300},
96
+ )
97
+
98
+ assert exported.status_code == 200
99
+ assert exported.headers["content-type"] == "image/png"
100
+ assert exported.content
101
+
102
+ disposition = exported.headers["content-disposition"]
103
+ encoded = disposition.split("filename*=UTF-8''", 1)[1]
104
+ assert unquote(encoded).startswith("北京市-japanese-ink-")
105
+
106
+
107
  def test_prepare_keeps_selected_bbox_for_portrait_ratio(monkeypatch, tmp_path) -> None:
108
  key = "b" * 64
109
  captured = {}
tests/test_cache_and_geocoding.py CHANGED
@@ -2,18 +2,28 @@ from __future__ import annotations
2
 
3
  from dataclasses import replace
4
 
 
 
 
5
  from maptoposter.cache import DiskCache, map_cache_key
6
  from maptoposter.china import search_china
7
- from maptoposter.geocoding import GeocodingService, parse_coordinate_query
 
 
 
 
 
8
  from maptoposter.models import Coordinate, Location
9
 
10
 
11
  class FakeProvider:
12
  def __init__(self) -> None:
13
  self.calls = 0
 
14
 
15
  def search(self, query: str, language: str, limit: int) -> list[Location]:
16
  self.calls += 1
 
17
  return [Location(query, Coordinate(1.25, 2.5), "Testland", provider="fake")]
18
 
19
 
@@ -50,3 +60,46 @@ def test_external_geocoder_result_is_cached(tmp_path) -> None:
50
  assert service.search("Atlantis")[0].provider == "fake"
51
  assert service.search("Atlantis")[0].provider == "fake"
52
  assert provider.calls == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from dataclasses import replace
4
 
5
+ import pytest
6
+
7
+ import maptoposter.geocoding as geocoding
8
  from maptoposter.cache import DiskCache, map_cache_key
9
  from maptoposter.china import search_china
10
+ from maptoposter.geocoding import (
11
+ GeocodingService,
12
+ NominatimProvider,
13
+ nominatim_language,
14
+ parse_coordinate_query,
15
+ )
16
  from maptoposter.models import Coordinate, Location
17
 
18
 
19
  class FakeProvider:
20
  def __init__(self) -> None:
21
  self.calls = 0
22
+ self.languages: list[str] = []
23
 
24
  def search(self, query: str, language: str, limit: int) -> list[Location]:
25
  self.calls += 1
26
+ self.languages.append(language)
27
  return [Location(query, Coordinate(1.25, 2.5), "Testland", provider="fake")]
28
 
29
 
 
60
  assert service.search("Atlantis")[0].provider == "fake"
61
  assert service.search("Atlantis")[0].provider == "fake"
62
  assert provider.calls == 1
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ ("language", "expected"),
67
+ [
68
+ ("en", "en"),
69
+ ("fr", "en"),
70
+ ("cn", "zh-CN"),
71
+ ("zh", "zh-CN"),
72
+ ("zh-CN", "zh-CN"),
73
+ ("ZH", "zh-CN"),
74
+ ],
75
+ )
76
+ def test_nominatim_language_maps_chinese_aliases(language: str, expected: str) -> None:
77
+ assert nominatim_language(language) == expected
78
+
79
+
80
+ @pytest.mark.parametrize(
81
+ ("requested", "expected"),
82
+ [("cn", "zh-CN"), ("zh", "zh-CN"), ("zh-CN", "zh-CN"), ("en", "en"), ("fr", "en")],
83
+ )
84
+ def test_nominatim_provider_forwards_localized_language(monkeypatch, requested: str, expected: str) -> None:
85
+ captured: list[str] = []
86
+
87
+ class FakeGeocoder:
88
+ def geocode(self, query: str, **kwargs: object) -> list[object]:
89
+ captured.append(str(kwargs.get("language")))
90
+ return []
91
+
92
+ monkeypatch.setattr(geocoding, "Nominatim", lambda **_: FakeGeocoder())
93
+
94
+ NominatimProvider().search("Tokyo", requested)
95
+
96
+ assert captured == [expected]
97
+
98
+
99
+ def test_geocoding_service_forwards_requested_language(tmp_path) -> None:
100
+ provider = FakeProvider()
101
+ service = GeocodingService(DiskCache(tmp_path), provider)
102
+
103
+ service.search("Atlantis", "zh")
104
+
105
+ assert provider.languages == ["zh"]
tests/test_cli.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for the documented command-line usage.
2
+
3
+ The README and AGENTS docs publish runnable commands. Previously the docs used
4
+ ``--output-format``, which no parser defined, so argparse rejected the command with
5
+ ``unrecognized arguments`` before any poster could be generated. These tests pin the
6
+ documented argv, the ``--output-format`` alias and the original ``--format``/``-f`` flags.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+ from pathlib import Path
13
+
14
+ from create_map_poster import build_parser as build_legacy_parser
15
+ from maptoposter.cli import build_parser
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+
19
+
20
+ def _documented_argv(command: str, prefix_length: int) -> list[str]:
21
+ """Split a documented command and drop the ``uv run ...`` prefix."""
22
+ argv = shlex.split(command)
23
+ return argv[prefix_length:]
24
+
25
+
26
+ def test_readme_documented_command_parses() -> None:
27
+ """README.md's CLI example must parse verbatim and select the png format."""
28
+ readme = (ROOT / "README.md").read_text(encoding="utf-8-sig")
29
+ command = 'uv run maptoposter --city "Beijing" --theme japanese_ink --output-format png'
30
+ assert command in readme
31
+
32
+ args = build_parser().parse_args(_documented_argv(command, 3))
33
+ assert args.city == "Beijing"
34
+ assert args.theme == "japanese_ink"
35
+ assert args.format == "png"
36
+
37
+
38
+ def test_agents_documented_legacy_command_parses() -> None:
39
+ """AGENTS.md's legacy example must parse verbatim, including --city/--country."""
40
+ agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
41
+ command = (
42
+ "uv run python create_map_poster.py --city \"Beijing\" --country \"China\" "
43
+ "--theme japanese_ink --output-format png"
44
+ )
45
+ assert command in agents
46
+
47
+ args = build_legacy_parser().parse_args(_documented_argv(command, 4))
48
+ assert args.city == "Beijing"
49
+ assert args.country == "China"
50
+ assert args.theme == "japanese_ink"
51
+ assert args.format == "png"
52
+
53
+
54
+ def test_output_format_alias_maps_to_format_dest() -> None:
55
+ """The alias resolves to the same ``format`` destination for both parsers."""
56
+ assert build_parser().parse_args(["--city", "Paris", "--output-format", "svg"]).format == "svg"
57
+ legacy = build_legacy_parser().parse_args(["--city", "Paris", "-C", "France", "--output-format", "pdf"])
58
+ assert legacy.format == "pdf"
59
+
60
+
61
+ def test_format_flags_remain_available() -> None:
62
+ """Existing --format and -f usage must keep working."""
63
+ assert build_parser().parse_args(["--city", "Paris", "--format", "svg"]).format == "svg"
64
+ assert build_parser().parse_args(["--city", "Paris", "-f", "pdf"]).format == "pdf"
65
+ assert build_legacy_parser().parse_args(["--city", "Paris", "-C", "France", "--format", "svg"]).format == "svg"
66
+
67
+
68
+ def test_help_advertises_output_format_alias() -> None:
69
+ """``--help`` must advertise the documented alias so users can discover it."""
70
+ assert "--output-format" in build_parser().format_help()
71
+ assert "--output-format" in build_legacy_parser().format_help()
tests/test_repository_contract.py CHANGED
@@ -48,7 +48,7 @@ def test_main_release_contract() -> None:
48
  workflow = (ROOT / ".github/workflows/publish-hugging-face.yml").read_text(encoding="utf-8")
49
  assert "branches: [main]" in workflow
50
  assert "needs: validate" in workflow
51
- assert "huggingface/hub-sync@v0.1.0" in workflow
52
  assert "huggingface_repo_id: isaachwf/MapToPoster" in workflow
53
  assert "hf_token: ${{ secrets.HF_TOKEN }}" in workflow
54
  assert "space_sdk: docker" in workflow
 
48
  workflow = (ROOT / ".github/workflows/publish-hugging-face.yml").read_text(encoding="utf-8")
49
  assert "branches: [main]" in workflow
50
  assert "needs: validate" in workflow
51
+ assert "huggingface/hub-sync@v0.3.0" in workflow
52
  assert "huggingface_repo_id: isaachwf/MapToPoster" in workflow
53
  assert "hf_token: ${{ secrets.HF_TOKEN }}" in workflow
54
  assert "space_sdk: docker" in workflow