mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 14:43:01 +02:00
feat: 14kB articles site
This commit is contained in:
parent
175a2b256c
commit
8f24526058
15
articles/README.md
Normal file
15
articles/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
Mini Articles (<=14KB)
|
||||
|
||||
- Single-file site: `index.html` with inline CSS & JS
|
||||
- Features:
|
||||
- List of articles with thumbnails (cards)
|
||||
- Read view: thumbnail, title, body (supports inline images/videos)
|
||||
- Create view: title, thumbnail picker/drag-drop, rich body via contenteditable
|
||||
- Drag/drop or choose images/videos anywhere in the body
|
||||
- Local persistence via localStorage (no server required)
|
||||
|
||||
How to open
|
||||
- Open `site/index.html` in a browser.
|
||||
|
||||
Tests
|
||||
- `pytest` includes a test to enforce the 14KB budget for `index.html`.
|
||||
1
articles/data/articles.json
Normal file
1
articles/data/articles.json
Normal file
File diff suppressed because one or more lines are too long
183
articles/index.html
Normal file
183
articles/index.html
Normal file
@ -0,0 +1,183 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Mini Articles</title>
|
||||
<style>
|
||||
/* Tiny, readable defaults */
|
||||
*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:16px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif;color:#222;background:#fff}
|
||||
header{display:flex;gap:.5rem;align-items:center;justify-content:space-between;padding:.5rem .75rem;border-bottom:1px solid #ddd;position:sticky;top:0;background:#fff}
|
||||
header h1{margin:0;font-size:1.1rem}
|
||||
header nav{display:flex;gap:.5rem}
|
||||
button, input[type=file]{font:inherit}
|
||||
main{max-width:960px;margin:0 auto;padding:1rem}
|
||||
.hidden{display:none!important}
|
||||
section.hidden{display:none!important}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem}
|
||||
.card{border:1px solid #ddd;border-radius:.5rem;overflow:hidden;background:#fff;cursor:pointer;display:flex;flex-direction:column}
|
||||
.card img{width:100%;height:120px;object-fit:cover;display:block}
|
||||
.card h3{margin:.5rem;font-size:1rem}
|
||||
#editor label{display:block;margin:.5rem 0 .25rem}
|
||||
input[type=text]{width:100%;padding:.5rem;border:1px solid #ccc;border-radius:.4rem}
|
||||
.drop{border:1.5px dashed #bbb;border-radius:.5rem;padding:.5rem;text-align:center;color:#666}
|
||||
.drop img{max-width:100%;max-height:200px;display:block;margin:.25rem auto}
|
||||
#content{min-height:200px;padding:.5rem;border:1px solid #ccc;border-radius:.5rem}
|
||||
#content:empty:before{content:attr(data-ph);color:#999}
|
||||
article img, article video{max-width:100%;height:auto;display:block;margin:.5rem 0}
|
||||
article h1{margin:.25rem 0 .5rem;font-size:1.6rem}
|
||||
.controls{display:flex;gap:.5rem;flex-wrap:wrap;margin:.5rem 0}
|
||||
.small{font-size:.85rem;color:#666}
|
||||
/* Focused reading mode: keep header headline, hide nav and extras */
|
||||
.reading header nav{display:none}
|
||||
.reading #delBtn,.reading #dateInfo{display:none}
|
||||
</style>
|
||||
|
||||
<header>
|
||||
<h1>Mini Articles</h1>
|
||||
<nav>
|
||||
<button id="toList" title="All articles">List</button>
|
||||
<button id="toNew" title="Create article">New</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<!-- List View -->
|
||||
<section id="listV" class="grid"></section>
|
||||
|
||||
<!-- Read View -->
|
||||
<section id="readV" class="hidden">
|
||||
<div class="controls">
|
||||
<button id="backBtn">← Back</button>
|
||||
<button id="delBtn" title="Delete">Delete</button>
|
||||
<span class="small" id="dateInfo"></span>
|
||||
</div>
|
||||
<article id="readA">
|
||||
<img id="readThumb" alt="thumbnail"/>
|
||||
<h1 id="readTitle"></h1>
|
||||
<div id="readBody"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Editor View -->
|
||||
<section id="editor" class="hidden">
|
||||
<label>Title</label>
|
||||
<input id="title" type="text" maxlength="200" placeholder="Article title"/>
|
||||
|
||||
<label>Thumbnail</label>
|
||||
<div id="thumbDrop" class="drop" tabindex="0">Drop image here or choose…<br><img id="thumbPrev" alt="thumbnail preview"/></div>
|
||||
<input id="thumbFile" type="file" accept="image/*"/>
|
||||
|
||||
<label>Body</label>
|
||||
<div class="controls">
|
||||
<button id="insImg">Insert image</button>
|
||||
<button id="insVid">Insert video</button>
|
||||
</div>
|
||||
<div id="content" contenteditable="true" data-ph="Write here. Drag & drop images/videos where you want them."></div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="saveBtn">Save</button>
|
||||
<button id="cancelBtn">Cancel</button>
|
||||
<span class="small">Stored locally or on server if available.</span>
|
||||
</div>
|
||||
<input id="imgFile" type="file" accept="image/*" hidden>
|
||||
<input id="vidFile" type="file" accept="video/*" hidden>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Tiny SPA with optional backend. Falls back to localStorage.
|
||||
(function(){
|
||||
const $=s=>document.querySelector(s);
|
||||
const listV=$('#listV'), readV=$('#readV'), editor=$('#editor');
|
||||
const rT=$('#readTitle'), rB=$('#readBody'), rTh=$('#readThumb'), dt=$('#dateInfo');
|
||||
const toList=$('#toList'), toNew=$('#toNew'), back=$('#backBtn'), del=$('#delBtn');
|
||||
const title=$('#title'), content=$('#content');
|
||||
const tDrop=$('#thumbDrop'), tPrev=$('#thumbPrev'), tFile=$('#thumbFile');
|
||||
const insImg=$('#insImg'), insVid=$('#insVid'), imgFile=$('#imgFile'), vidFile=$('#vidFile');
|
||||
const saveBtn=$('#saveBtn'), cancelBtn=$('#cancelBtn');
|
||||
const KEY='articles14k';
|
||||
let selId=null; let API=null;
|
||||
|
||||
// API client with detection
|
||||
async function detectAPI(){
|
||||
try{const r=await fetch('/api/articles'); if(r.ok){return {
|
||||
async list(){return (await fetch('/api/articles')).json()},
|
||||
async get(id){const r=await fetch('/api/articles/'+id); if(!r.ok) return null; return r.json()},
|
||||
async create(a){const r=await fetch('/api/articles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(a)}); return r.json()},
|
||||
async update(id,patch){const r=await fetch('/api/articles/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); return r.ok? r.json():null},
|
||||
async remove(id){await fetch('/api/articles/'+id,{method:'DELETE'})}
|
||||
}} }catch(e){}
|
||||
// Fallback to localStorage
|
||||
const S={get(){try{return JSON.parse(localStorage.getItem(KEY)||'[]')}catch(e){return []}},set(a){localStorage.setItem(KEY,JSON.stringify(a))}};
|
||||
const uid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,6);
|
||||
return {
|
||||
async list(){return S.get()},
|
||||
async get(id){return S.get().find(x=>x.id===id)||null},
|
||||
async create(a){const arr=S.get(); const obj=Object.assign({id:uid(),createdAt:Date.now()},a); arr.unshift(obj); S.set(arr); return obj},
|
||||
async update(id,patch){const arr=S.get(); const i=arr.findIndex(x=>x.id===id); if(i<0) return null; arr[i]=Object.assign(arr[i],patch,{updatedAt:Date.now()}); S.set(arr); return arr[i]},
|
||||
async remove(id){const arr=S.get().filter(x=>x.id!==id); S.set(arr)}
|
||||
};
|
||||
}
|
||||
|
||||
function show(v){[listV,readV,editor].forEach(x=>x.classList.add('hidden')); v.classList.remove('hidden'); document.documentElement.classList.toggle('reading', v===readV)}
|
||||
|
||||
async function renderList(){
|
||||
const a=await API.list();
|
||||
if(!a.length){listV.innerHTML='<div class="small">No articles yet. Click New to create one.</div>';return}
|
||||
listV.innerHTML=a.map(x=>`<div class="card" data-id="${x.id}"><img src="${x.thumb||''}" alt="thumb"><h3>${esc(x.title)}</h3></div>`).join('');
|
||||
}
|
||||
|
||||
function esc(s){return (s||'').replace(/[&<>]/g,m=>({"&":"&","<":"<",">":">"}[m]))}
|
||||
|
||||
function pick(el,cb){el.onchange=e=>{const f=e.target.files&&e.target.files[0]; if(f) fileToDataURL(f).then(cb)}; el.click()}
|
||||
function fileToDataURL(f){return new Promise((res,rej)=>{const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(f)})}
|
||||
function insertAtSel(node){const s=window.getSelection(); if(!s||!s.rangeCount){content.appendChild(node); return} const r=s.getRangeAt(0); r.deleteContents(); r.insertNode(node); r.setStartAfter(node); r.setEndAfter(node); s.removeAllRanges(); s.addRange(r)}
|
||||
function dropHandler(zone, cb){zone.addEventListener('dragover',e=>{e.preventDefault(); zone.style.background='#f7f7f7'}); zone.addEventListener('dragleave',()=>zone.style.background=''); zone.addEventListener('drop',e=>{e.preventDefault(); zone.style.background=''; const fs=[...(e.dataTransfer.files||[])]; fs.forEach(cb)});}
|
||||
|
||||
// Thumb drag/drop or file select
|
||||
dropHandler(tDrop, f=>{ if(!f.type.startsWith('image/')) return; fileToDataURL(f).then(u=>{tPrev.src=u})});
|
||||
tFile.addEventListener('change',e=>{const f=e.target.files[0]; if(f) fileToDataURL(f).then(u=>tPrev.src=u)});
|
||||
tDrop.addEventListener('click',()=>tFile.click());
|
||||
|
||||
// Content drag/drop insert and paste
|
||||
content.addEventListener('paste',e=>{const it=(e.clipboardData||{}).items||[]; for(const x of it){const f=x.getAsFile&&x.getAsFile(); if(!f) continue; e.preventDefault(); if(f.type.startsWith('image/')) fileToDataURL(f).then(u=>{const img=new Image(); img.src=u; insertAtSel(img)})
|
||||
else if(f.type.startsWith('video/')) fileToDataURL(f).then(u=>{const v=document.createElement('video'); v.controls=true; v.src=u; v.style.maxWidth='100%'; insertAtSel(v)}) } });
|
||||
dropHandler(content, f=>{ if(f.type.startsWith('image/')) { fileToDataURL(f).then(u=>{const img=new Image(); img.src=u; insertAtSel(img)}) } else if(f.type.startsWith('video/')) { fileToDataURL(f).then(u=>{const v=document.createElement('video'); v.controls=true; v.src=u; v.style.maxWidth='100%'; insertAtSel(v)}) } });
|
||||
|
||||
// Insert buttons
|
||||
insImg.onclick=()=>pick(imgFile, u=>{const img=new Image(); img.src=u; insertAtSel(img)});
|
||||
insVid.onclick=()=>pick(vidFile, u=>{const v=document.createElement('video'); v.controls=true; v.src=u; v.style.maxWidth='100%'; insertAtSel(v)});
|
||||
|
||||
// Save article
|
||||
saveBtn.onclick=async()=>{
|
||||
let th=tPrev.src||''; if(!th){const fi=content.querySelector('img'); if(fi) th=fi.src}
|
||||
const obj={title:title.value.trim()||'Untitled', body:content.innerHTML, thumb:th||''};
|
||||
await API.create(obj); selId=null; clearEditor(); await renderList(); show(listV);
|
||||
};
|
||||
|
||||
function clearEditor(){ title.value=''; content.innerHTML=''; tPrev.removeAttribute('src') }
|
||||
cancelBtn.onclick=()=>{selId=null; clearEditor(); show(listV)};
|
||||
|
||||
// List click -> read (ensure full focus by switching view)
|
||||
listV.addEventListener('click',async e=>{const card=e.target.closest('.card'); if(!card) return; const id=card.getAttribute('data-id'); const a=await API.get(id); if(!a) return; openRead(a)});
|
||||
function openRead(a){ selId=a.id; rTh.src=a.thumb||''; rT.textContent=a.title||''; rB.innerHTML=a.body||''; dt.textContent=new Date(a.createdAt||Date.now()).toLocaleString(); show(readV) }
|
||||
|
||||
// Delete
|
||||
del.onclick=async()=>{ if(!selId) return; await API.remove(selId); selId=null; await renderList(); show(listV) };
|
||||
|
||||
// Nav
|
||||
toList.onclick=async()=>{await renderList(); show(listV)};
|
||||
toNew.onclick=()=>{selId=null; clearEditor(); show(editor)};
|
||||
back.onclick=()=>{show(listV)};
|
||||
|
||||
// No deep linking; keep UI simple and focused
|
||||
|
||||
(async function init(){
|
||||
API=await detectAPI();
|
||||
const items=await API.list();
|
||||
if(!items.length){await API.create({title:'Welcome to Mini Articles', body:'<p>Edit or create your first article. Drop images or videos right into the text.</p>', thumb:''});}
|
||||
await renderList();
|
||||
show(listV);
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</html>
|
||||
46
articles/run.sh
Executable file
46
articles/run.sh
Executable file
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run Mini Articles (backend + frontend)
|
||||
# Options (env): HOST (default 127.0.0.1), PORT (default 8000), ARTICLES_DATA_DIR
|
||||
|
||||
DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SITE_DIR="$DIR"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-8000}"
|
||||
PYTHON_BIN="${PYTHON:-}"
|
||||
|
||||
if [[ -z "${PYTHON_BIN}" ]]; then
|
||||
if command -v python >/dev/null 2>&1; then PYTHON_BIN=python
|
||||
elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3
|
||||
else
|
||||
echo "Python is required but not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SITE_DIR/server.py" ]]; then
|
||||
echo "server.py not found in $SITE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start server in background
|
||||
export HOST PORT ARTICLES_DATA_DIR
|
||||
"$PYTHON_BIN" "$SITE_DIR/server.py" &
|
||||
SRV_PID=$!
|
||||
trap 'kill $SRV_PID 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
# Give it a moment to start
|
||||
sleep 0.5
|
||||
URL="http://$HOST:$PORT/"
|
||||
|
||||
# Try to open browser on Linux
|
||||
if command -v xdg-open >/dev/null 2>&1; then
|
||||
xdg-open "$URL" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Mini Articles running at $URL"
|
||||
|
||||
echo "Press Ctrl+C to stop."
|
||||
# Wait on server
|
||||
wait "$SRV_PID"
|
||||
19
articles/run_tests.sh
Executable file
19
articles/run_tests.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run only the website tests from this directory
|
||||
DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
cd "$DIR"
|
||||
|
||||
PYTHON_BIN="${PYTHON:-}"
|
||||
if [[ -z "${PYTHON_BIN}" ]]; then
|
||||
if command -v python >/dev/null 2>&1; then PYTHON_BIN=python
|
||||
elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3
|
||||
else
|
||||
echo "Python is required but not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Be explicit to avoid collecting tests from other repo paths
|
||||
"$PYTHON_BIN" -m pytest -q test_site_size.py test_server_api.py
|
||||
191
articles/server.py
Normal file
191
articles/server.py
Normal file
@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
from http import HTTPStatus
|
||||
from http.server import SimpleHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Storage helpers
|
||||
|
||||
def _data_dir():
|
||||
base = os.environ.get("ARTICLES_DATA_DIR") or os.path.join(os.path.dirname(__file__), "data")
|
||||
os.makedirs(base, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def _data_file():
|
||||
return os.path.join(_data_dir(), "articles.json")
|
||||
|
||||
|
||||
def _load():
|
||||
fp = _data_file()
|
||||
if not os.path.exists(fp):
|
||||
return []
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save(articles):
|
||||
with open(_data_file(), "w", encoding="utf-8") as f:
|
||||
json.dump(articles, f, ensure_ascii=False)
|
||||
|
||||
|
||||
def _uid():
|
||||
return f"{int(__import__('time').time()*1000):x}{random.randrange(1<<20):x}"[:16]
|
||||
|
||||
|
||||
class App(SimpleHTTPRequestHandler):
|
||||
# Serve static from the site directory
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=os.path.dirname(__file__), **kwargs)
|
||||
|
||||
# CORS / common headers
|
||||
def _headers(self, code=200, ctype="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
if self.path.startswith("/api/"):
|
||||
self._headers(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
else:
|
||||
super().do_OPTIONS()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/articles"):
|
||||
self._api_get()
|
||||
else:
|
||||
# Serve static (index.html for root)
|
||||
if self.path == "/":
|
||||
self.path = "/index.html"
|
||||
return super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/api/articles":
|
||||
self._api_post()
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint")
|
||||
|
||||
def do_PUT(self):
|
||||
if self.path.startswith("/api/articles/"):
|
||||
self._api_put()
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint")
|
||||
|
||||
def do_DELETE(self):
|
||||
if self.path.startswith("/api/articles/"):
|
||||
self._api_delete()
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint")
|
||||
|
||||
# --- API methods ---
|
||||
def _read_json(self):
|
||||
try:
|
||||
ln = int(self.headers.get('Content-Length', 0))
|
||||
except Exception:
|
||||
ln = 0
|
||||
raw = self.rfile.read(ln) if ln > 0 else b""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _api_get(self):
|
||||
parts = urlparse(self.path).path.strip("/").split("/")
|
||||
arts = sorted(_load(), key=lambda x: x.get("createdAt", 0), reverse=True)
|
||||
if len(parts) == 1: # /api
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if len(parts) == 2 and parts[1] == "articles":
|
||||
self._headers(HTTPStatus.OK)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(arts).encode("utf-8"))
|
||||
return
|
||||
# /api/articles/<id>
|
||||
if len(parts) == 3 and parts[1] == "articles":
|
||||
art = next((a for a in arts if a.get("id") == parts[2]), None)
|
||||
if not art:
|
||||
self._headers(HTTPStatus.NOT_FOUND)
|
||||
self.end_headers()
|
||||
return
|
||||
self._headers(HTTPStatus.OK)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(art).encode("utf-8"))
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def _api_post(self):
|
||||
data = self._read_json()
|
||||
title = (data.get("title") or "Untitled").strip()
|
||||
body = data.get("body") or ""
|
||||
thumb = data.get("thumb") or ""
|
||||
art = {"id": _uid(), "title": title, "body": body, "thumb": thumb, "createdAt": int(__import__('time').time()*1000)}
|
||||
arts = _load()
|
||||
arts.insert(0, art)
|
||||
_save(arts)
|
||||
self._headers(HTTPStatus.CREATED)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(art).encode("utf-8"))
|
||||
|
||||
def _api_put(self):
|
||||
art_id = urlparse(self.path).path.rsplit("/", 1)[-1]
|
||||
data = self._read_json()
|
||||
arts = _load()
|
||||
for i, a in enumerate(arts):
|
||||
if a.get("id") == art_id:
|
||||
a.update({k: v for k, v in data.items() if k in ("title", "body", "thumb")})
|
||||
a["updatedAt"] = int(__import__('time').time()*1000)
|
||||
arts[i] = a
|
||||
_save(arts)
|
||||
self._headers(HTTPStatus.OK)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(a).encode("utf-8"))
|
||||
return
|
||||
self._headers(HTTPStatus.NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def _api_delete(self):
|
||||
art_id = urlparse(self.path).path.rsplit("/", 1)[-1]
|
||||
arts = _load()
|
||||
new_arts = [a for a in arts if a.get("id") != art_id]
|
||||
if len(new_arts) == len(arts):
|
||||
self._headers(HTTPStatus.NOT_FOUND)
|
||||
self.end_headers()
|
||||
return
|
||||
_save(new_arts)
|
||||
self._headers(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
def serve(host="127.0.0.1", port=8000):
|
||||
httpd = HTTPServer((host, port), App)
|
||||
print(f"Serving Mini Articles on http://{host}:{port}")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def make_server(host="127.0.0.1", port=0):
|
||||
httpd = HTTPServer((host, port), App)
|
||||
th = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
th.start()
|
||||
return httpd, th
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.environ.get("HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("PORT", "8000"))
|
||||
serve(host, port)
|
||||
72
articles/test_server_api.py
Normal file
72
articles/test_server_api.py
Normal file
@ -0,0 +1,72 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
SITE_DIR = os.path.join(os.path.dirname(__file__), '..', 'site')
|
||||
import sys
|
||||
sys.path.insert(0, SITE_DIR)
|
||||
|
||||
from server import make_server # type: ignore
|
||||
|
||||
|
||||
def _req(url, method="GET", data=None):
|
||||
if data is not None and not isinstance(data, (bytes, bytearray)):
|
||||
data = json.dumps(data).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = resp.read()
|
||||
return resp.getcode(), body
|
||||
|
||||
|
||||
def test_crud_roundtrip(tmp_path):
|
||||
# Isolate storage
|
||||
os.environ["ARTICLES_DATA_DIR"] = str(tmp_path)
|
||||
|
||||
httpd, th = make_server(port=0)
|
||||
host, port = httpd.server_address
|
||||
base = f"http://{host}:{port}"
|
||||
|
||||
# Create
|
||||
code, body = _req(base+"/api/articles", method="POST", data={
|
||||
"title": "T1",
|
||||
"body": "<p>Hello</p>",
|
||||
"thumb": "data:image/png;base64,xyz"
|
||||
})
|
||||
assert code == 201
|
||||
created = json.loads(body)
|
||||
art_id = created["id"]
|
||||
|
||||
# List
|
||||
code, body = _req(base+"/api/articles")
|
||||
assert code == 200
|
||||
items = json.loads(body)
|
||||
assert any(a["id"] == art_id for a in items)
|
||||
|
||||
# Get one
|
||||
code, body = _req(base+f"/api/articles/{art_id}")
|
||||
assert code == 200
|
||||
got = json.loads(body)
|
||||
assert got["title"] == "T1"
|
||||
|
||||
# Update
|
||||
code, body = _req(base+f"/api/articles/{art_id}", method="PUT", data={"title": "T2"})
|
||||
assert code == 200
|
||||
updated = json.loads(body)
|
||||
assert updated["title"] == "T2"
|
||||
|
||||
# Delete
|
||||
code, _ = _req(base+f"/api/articles/{art_id}", method="DELETE")
|
||||
assert code == 204
|
||||
|
||||
# Ensure gone
|
||||
try:
|
||||
_req(base+f"/api/articles/{art_id}")
|
||||
assert False, "Expected 404"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 404
|
||||
|
||||
httpd.shutdown()
|
||||
th.join(timeout=2)
|
||||
16
articles/test_site_size.py
Normal file
16
articles/test_site_size.py
Normal file
@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
# Budget for the entire website (single file) in bytes
|
||||
BUDGET = 14 * 1024 # 14 KiB
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
SITE_FILE = os.path.join(HERE, 'index.html')
|
||||
|
||||
|
||||
def test_site_file_exists():
|
||||
assert os.path.exists(SITE_FILE), f"Missing site file: {SITE_FILE}"
|
||||
|
||||
|
||||
def test_site_size_under_budget():
|
||||
size = os.path.getsize(SITE_FILE)
|
||||
assert size <= BUDGET, f"Site size {size} bytes exceeds budget {BUDGET}"
|
||||
@ -1,37 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import chess
|
||||
|
||||
# Ensure repo root in path for 'PYTHON' package imports when running locally
|
||||
REPO_ROOT = os.path.dirname(os.path.abspath(__file__ + "/.."))
|
||||
PARENT = os.path.dirname(REPO_ROOT)
|
||||
if PARENT not in sys.path:
|
||||
sys.path.insert(0, PARENT)
|
||||
|
||||
from PYTHON.lichess_bot.engine import RandomEngine # noqa: E402
|
||||
|
||||
|
||||
def position_after_italian_bg5():
|
||||
# 1.e4 e5 2.Nf3 Nc6 3.Bc4 Nf6 4.d3 Bc5 5.O-O d6 6.Bg5
|
||||
moves = [
|
||||
"e4", "e5", "Nf3", "Nc6", "Bc4", "Nf6", "d3", "Bc5", "O-O", "d6", "Bg5"
|
||||
]
|
||||
board = chess.Board()
|
||||
for san in moves:
|
||||
board.push_san(san)
|
||||
return board
|
||||
|
||||
|
||||
def test_engine_avoids_unsound_bxf2_in_italian_bg5():
|
||||
board = position_after_italian_bg5()
|
||||
eng = RandomEngine(depth=4, max_time_sec=1.5)
|
||||
move, expl = eng.choose_move_with_explanation(board, time_budget_sec=1.5)
|
||||
# The engine should avoid Bxf2+ here (known blunder); assert chosen move isn't that
|
||||
bxf2 = chess.Move.from_uci("c5f2")
|
||||
assert move != bxf2, f"Engine picked unsound Bxf2+: {expl}"
|
||||
|
||||
# Also ensure Bxf2+ is not the top candidate in analyze list for small depth
|
||||
# We can do a weaker invariant: if the engine considered only a couple moves earlier,
|
||||
# ensure current top move either equals move or is not Bxf2.
|
||||
assert move is not None
|
||||
assert move is None or move != bxf2
|
||||
Loading…
Reference in New Issue
Block a user