Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907ad537e2 | ||
|
|
51728bf3f2 | ||
|
|
8f76f70409 | ||
|
|
8c94d40766 | ||
|
|
1ffaf7478d |
@@ -206,6 +206,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
|
||||
if old_val != new_val:
|
||||
changed_fields[field] = {"old": old_val, "new": new_val}
|
||||
if changed_fields:
|
||||
# Keep the queryable normalized record in sync with its
|
||||
# versioned source payload.
|
||||
report.fish = fish
|
||||
report.waterbody = waterbody
|
||||
report.bait = bait
|
||||
report.weight_g = raw.weight_g
|
||||
report.player_name = raw.player
|
||||
report.caught_at = caught
|
||||
report.raw_payload = payload
|
||||
report.source_url = url
|
||||
session.add(ImportRecordEvent(
|
||||
|
||||
@@ -32,9 +32,10 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||||
<a class="back" href="/">← Все активные точки</a>
|
||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||
<ActivityTimeline buckets={timeline}/>
|
||||
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
||||
<section class="detail-grid">
|
||||
<div>
|
||||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||||
@@ -44,3 +45,13 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
</section>
|
||||
</>}
|
||||
</Layout>
|
||||
<script is:inline>
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const value = button.dataset.copyCoordinates || "";
|
||||
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
|
||||
try { await navigator.clipboard.writeText(value); if (status) status.textContent = "Скопировано"; }
|
||||
catch { if (status) status.textContent = value; }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,7 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
||||
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
|
||||
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
.activity-legend{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0;color:#7d918b;font-size:11px}.activity-legend span:before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;background:var(--lime)}.activity-legend span:nth-child(2):before{background:#e5b95c}.activity-legend span:nth-child(3):before{background:#ef795e}
|
||||
|
||||
/* RF4 field-guide iconography and float activity gauge */
|
||||
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
|
||||
|
||||
@@ -49,7 +49,7 @@ if ! ALEMBIC_HEADS_OUTPUT=$($compose exec -T api alembic heads 2>/dev/null); the
|
||||
exit 1
|
||||
fi
|
||||
# Extract revision IDs (first field before space or '(head)'), handle multiple heads
|
||||
ALEMBIC_HEAD=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -oE '^[a-f0-9]+' | head -1)
|
||||
ALEMBIC_HEAD=$(printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk 'NF {print $1; exit}')
|
||||
DB_VERSION=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')
|
||||
|
||||
if [ -z "$ALEMBIC_HEAD" ]; then
|
||||
@@ -63,10 +63,10 @@ if [ -z "$DB_VERSION" ]; then
|
||||
fi
|
||||
|
||||
# Handle multiple heads: check if DB version matches any head
|
||||
HEAD_COUNT=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -cE '^[a-f0-9]+') || HEAD_COUNT=0
|
||||
HEAD_COUNT=$(printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk 'NF {count++} END {print count+0}')
|
||||
if [ "$HEAD_COUNT" -gt 1 ]; then
|
||||
echo "WARNING: Multiple Alembic heads detected ($HEAD_COUNT), checking if DB version matches any..."
|
||||
if ! echo "$ALEMBIC_HEADS_OUTPUT" | grep -q "^$DB_VERSION"; then
|
||||
if ! printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk '{print $1}' | grep -Fxq "$DB_VERSION"; then
|
||||
echo "ERROR: DB version $DB_VERSION does not match any head. Heads: $ALEMBIC_HEADS_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+21
-17
@@ -137,24 +137,28 @@ def test_check_and_reserve_atomic_under_concurrent_access(tmp_path: Path) -> Non
|
||||
import multiprocessing
|
||||
|
||||
state_file = tmp_path / "concurrent.json"
|
||||
results = multiprocessing.Manager().list()
|
||||
# Explicitly shut down the manager and never leak a timed-out child.
|
||||
with multiprocessing.Manager() as manager:
|
||||
results = manager.list()
|
||||
processes = []
|
||||
for i in range(3):
|
||||
args = (i, str(state_file), results)
|
||||
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,))
|
||||
processes.append(p)
|
||||
for p in processes:
|
||||
p.start()
|
||||
for p in processes:
|
||||
p.join(timeout=10)
|
||||
for p in processes:
|
||||
if p.is_alive():
|
||||
p.terminate()
|
||||
p.join(timeout=2)
|
||||
|
||||
# Launch 3 processes simultaneously
|
||||
processes = []
|
||||
for i in range(3):
|
||||
args = (i, str(state_file), results)
|
||||
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,))
|
||||
processes.append(p)
|
||||
for p in processes:
|
||||
p.start()
|
||||
for p in processes:
|
||||
p.join(timeout=10)
|
||||
|
||||
# At most one should succeed
|
||||
ok_count = sum(1 for _, r in results if r == "ok")
|
||||
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
|
||||
denied_count = sum(1 for _, r in results if "cooldown" in r)
|
||||
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
|
||||
# At most one should succeed
|
||||
ok_count = sum(1 for _, r in results if r == "ok")
|
||||
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
|
||||
denied_count = sum(1 for _, r in results if "cooldown" in r)
|
||||
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
|
||||
|
||||
|
||||
# A03: Manual redirect control tests
|
||||
|
||||
Reference in New Issue
Block a user