feat: add common utilities and feedback handling scripts

- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
This commit is contained in:
jsahn
2026-10-06 23:14:45 +09:00
parent 04158ac442
commit 4e943700fc
62 changed files with 158771 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import json
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ["ATLAS_OWNER_PASSWORD"] = "owner-pass"
@pytest.fixture()
def client(tmp_path, monkeypatch):
from fastapi.testclient import TestClient
from server import db
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
db._local.__dict__.clear()
from server import app as app_module
app_module._hits.clear()
with TestClient(app_module.app) as c:
yield c
db._local.__dict__.clear()
@pytest.fixture()
def admin(client):
r = client.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
assert r.status_code == 200
return client
@pytest.fixture(scope="session")
def atlas():
return json.loads((ROOT / "data" / "atlas.json").read_text())
@pytest.fixture()
def owner(client):
r = client.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
assert r.status_code == 200 and r.json()["role"] == "owner"
return client
+21
View File
@@ -0,0 +1,21 @@
// 관리 화면의 모든 탭이 오류 없이 열리는지 확인 (브라우저 필요: npm i playwright-core, Chrome 설치)
// node tests/e2e/admin-tabs.mjs http://127.0.0.1:8000 <교수자 비밀번호>
import { chromium } from "playwright-core";
const B = process.argv[2] || "http://127.0.0.1:8000", PW = process.argv[3] || "ownerpass";
const browser = await chromium.launch({ channel: "chrome", headless: true });
const p = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const errs = []; p.on("pageerror", e => errs.push(e.message));
await p.goto(B + "/login?next=/admin", { waitUntil: "networkidle" });
await p.fill("[name=username]", "professor"); await p.fill("[name=password]", PW); await p.click("button[type=submit]"); await p.waitForTimeout(1500);
const tabs = await p.$$eval("#tabs a", (as) => as.map((a) => a.getAttribute("href")));
const extra = ["#members/list", "#members/codes", "#members/classes", "#members/stats", "#term/t_20cb03023b", "#questions/t_20cb03023b"];
let bad = 0;
for (const h of [...tabs, ...extra]) {
await p.evaluate((x) => (location.hash = x), h); await p.waitForTimeout(900);
const t = await p.locator("#view").innerText();
const fail = /불러오지 못했습니다|Can't find|is not defined|undefined/.test(t);
if (fail) bad++;
console.log((fail ? "FAIL " : "PASS ") + h + (fail ? " → " + t.slice(0, 120) : ""));
}
console.log(errs.join("\n") || "no JS errors", "| 실패", bad);
await browser.close();
+41
View File
@@ -0,0 +1,41 @@
// node --test tests/search.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { buildIndex, search, choseong, initialGroup } from "../web/static/js/search.js";
import { shortestPath } from "../web/static/js/layout.js";
const terms = [
{ i: "a", n: "우선순위 설정", a: [], q: ["무엇부터"], x: "여러 문제 가운데 먼저 다룰 것을 정함" },
{ i: "b", n: "건강신념모형", a: ["Health Belief Model", "HBM"], q: [], x: "" },
{ i: "c", n: "발생률", a: ["incidence"], q: [], x: "새롭게 발생한 환자 비율" },
{ i: "d", n: "유병률", a: ["prevalence"], q: [], x: "질병을 가진 사람 비율" },
];
const idx = buildIndex(terms);
const top = (q) => search(idx, q)[0]?.t.i;
test("부분·동의어·설명 검색", () => {
assert.equal(top("우선"), "a");
assert.equal(top("hbm"), "b");
assert.equal(top("prevalence"), "d");
assert.equal(top("새롭게"), "c");
assert.equal(top("무엇부터 먼저"), "a");
});
test("초성·오타", () => {
assert.equal(top("ㄱㄱㅅㄴ"), "b");
assert.equal(top("우선순이"), "a");
assert.equal(choseong("건강"), "ㄱㄱ");
});
test("결과 없음", () => assert.equal(search(idx, "쀍쀍").length, 0));
test("가나다 묶음", () => {
assert.equal(initialGroup("쌍둥이"), "ㅅ");
assert.equal(initialGroup("WHO"), "A–Z");
});
test("최단 경로", () => {
const rels = [{ i: "r1", s: "a", t: "b" }, { i: "r2", s: "c", t: "b" }];
const adj = new Map(terms.map((t) => [t.i, []]));
for (const r of rels) { adj.get(r.s).push(r); adj.get(r.t).push(r); }
const p = shortestPath(adj, "a", "c");
assert.deepEqual(p.map((s) => s.to), ["b", "c"]);
assert.equal(p[1].forward, false);
assert.equal(shortestPath(adj, "a", "d"), null);
});
+126
View File
@@ -0,0 +1,126 @@
"""웹서버 API 회귀 테스트 (임시 DB에 data/atlas.json을 가져와 실행)."""
def first_term(client):
d = client.get("/api/atlas").json()
return d, d["terms"][0]["i"]
def test_atlas_shape(client):
d, _ = first_term(client)
assert len(d["galaxies"]) == 9
assert len(d["terms"]) > 1000
assert d["sections"] and d["relations"]
t = d["terms"][0]
assert {"i", "n", "g", "s", "st", "a"} <= t.keys()
def test_term_detail_and_404(client):
_, tid = first_term(client)
t = client.get(f"/api/terms/{tid}").json()
assert t["term_id"] == tid and "sources" in t and "relations" in t
assert client.get("/api/terms/t_nope").status_code == 404
def test_feedback_flow(client, admin):
_, tid = first_term(client)
r = client.post("/api/feedback", json={"kind": "content", "category": "뜻·정의가 틀림", "target_type": "term",
"target_id": tid, "message": "정의가 잘렸어요", "contact": "a@b.c"})
fid = r.json()["id"]
pub = client.get("/api/feedback").json()["items"][0]
assert pub["id"] == fid and "contact" not in pub
r = admin.patch(f"/api/admin/feedback/{fid}", json={"status": "resolved", "resolution": "수정함"})
assert r.json()["status"] == "resolved"
assert admin.get(f"/api/admin/feedback/{fid}").json()["handled_by"] == "교수자"
assert client.post("/api/feedback", json={"kind": "content", "message": "x"}).status_code == 422
assert client.post("/api/feedback", json={"kind": "content", "message": "없는 용어", "target_type": "term",
"target_id": "t_nope"}).status_code == 400
def test_client_error_dedup(client, admin):
for _ in range(3):
client.post("/api/client-errors", json={"message": "TypeError: boom", "stack": "at a.js:1"})
items = admin.get("/api/admin/feedback?kind=client_error").json()["items"]
assert len(items) == 1 and items[0]["occurrences"] == 3
def test_admin_requires_login(client):
assert client.get("/api/admin/stats").status_code == 401
assert client.post("/api/admin/login", json={"username": "professor", "password": "wrong"}).status_code == 401
assert client.post("/api/admin/login", json={"username": "professor", "password": "test-pass"}).status_code == 401 # 공용 비밀번호 없음
def test_term_edit_survives_reseed(admin):
_, tid = first_term(admin)
r = admin.put(f"/api/admin/terms/{tid}", json={"easy": "검수한 설명", "review_status": "confirmed"})
assert r.status_code == 200 and r.json()["easy"] == "검수한 설명"
n_src = len(r.json()["sources"])
admin.post("/api/admin/reseed")
t = admin.get(f"/api/admin/terms/{tid}").json()
assert t["easy"] == "검수한 설명" and t["review_status"] == "confirmed"
assert len(t["sources"]) == n_src # 출처 중복 없음
def test_public_status_filter(owner):
admin = owner
_, tid = first_term(admin)
admin.put(f"/api/admin/terms/{tid}", json={"review_status": "confirmed"})
assert admin.put("/api/admin/settings", json={"public_statuses": ["confirmed"]}).status_code == 200
d = admin.get("/api/atlas").json()
assert [t["i"] for t in d["terms"]] == [tid]
def test_relation_crud_and_merge(admin):
d, _ = first_term(admin)
a, b, c = d["terms"][1]["i"], d["terms"][2]["i"], d["terms"][3]["i"]
r = admin.post("/api/admin/relations", json={"source": a, "target": b, "type": "precedes", "label": "다음"})
rid = r.json()["relation_id"]
assert admin.post("/api/admin/relations", json={"source": a, "target": b, "type": "precedes"}).status_code == 409
admin.post("/api/admin/relations", json={"source": c, "target": b, "type": "precedes"})
admin.post(f"/api/admin/terms/{c}/merge", json={"into": a})
rels = admin.get(f"/api/admin/relations?term={a}&limit=1000").json()["items"]
assert sum(1 for x in rels if x["source"] == a and x["target"] == b and x["type"] == "precedes") == 1
assert admin.delete(f"/api/admin/relations/{rid}").json()["ok"]
def test_section_move_moves_terms(admin):
d, _ = first_term(admin)
sec = d["sections"][0]
admin.put(f"/api/admin/toc/{sec['id']}", json={"galaxy_id": "g9"})
terms = admin.get(f"/api/admin/terms?section={sec['id']}&limit=1000").json()["items"]
assert terms and all(t["galaxy_id"] == "g9" for t in terms)
def test_pages_served(client):
for p in ("/", "/feedback", "/admin", "/static/js/atlas.js", "/static/css/base.css"):
assert client.get(p).status_code == 200
def test_no_textbook_info_public(client):
"""저작권: 학생 화면(비로그인)에는 교과서 원문·문장·교재명·쪽수를 보내지 않는다."""
d = client.get("/api/atlas").json()
assert d["books"] == []
assert all("page" not in s and "book_id" not in s for s in d["sections"])
_, tid = first_term(client)
t = client.get(f"/api/terms/{tid}").json()
assert t["sources"] == [] and "definition_text" not in t and "definition_page" not in t
assert client.get("/pdf/x").status_code == 404
def test_admin_keeps_sources(admin):
_, tid = first_term(admin)
t = admin.get(f"/api/admin/terms/{tid}").json()
assert t["sources"] and "book_short" in t["sources"][0]
assert admin.get("/pdf/x").status_code == 404 # 원문 PDF는 관리자에게도 내보내지 않음
def test_unified_login_and_account(client):
# 교수자 아이디로 /api/login → 관리자 세션
r = client.post("/api/login", json={"username": "professor", "password": "owner-pass"}).json()
assert r["kind"] == "admin"
assert client.get("/api/admin/stats").status_code == 200
assert client.put("/api/admin/account", json={"current_password": "wrong", "username": "prof2"}).status_code == 400
r = client.put("/api/admin/account", json={"current_password": "owner-pass", "username": "prof2", "name": "김교수", "new_password": "newpass99"})
assert r.json()["username"] == "prof2"
assert client.post("/api/login", json={"username": "professor", "password": "owner-pass"}).status_code == 401
assert client.post("/api/login", json={"username": "prof2", "password": "newpass99"}).json()["kind"] == "admin"
+89
View File
@@ -0,0 +1,89 @@
"""회원 가입·승인(소유자만, 반 지정)·사례/문제 회원 전용."""
from fastapi.testclient import TestClient
def _term_with_example(client):
d = client.get("/api/atlas").json()
for t in d["terms"]:
if t["e"]:
r = client.get(f"/api/terms/{t['i']}").json()
if r.get("example_locked"):
return t["i"]
raise AssertionError("사례가 있는 용어가 없음")
def test_member_flow(client):
from server.app import app
# 비회원: 사례 잠김, 문제 불가
tid = _term_with_example(client)
assert client.get(f"/api/members/questions?term={tid}").status_code == 401
# 가입 코드 없이는 가입 불가
assert client.post("/api/members/signup", json={"code": "NOPE", "username": "stud1", "password": "secret1", "name": "김학생"}).status_code == 400
with TestClient(app) as own:
own.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
assert own.post("/api/admin/signup-codes", json={"max_uses": 2}).status_code == 400 # 반 없는 코드 불가
cid = own.post("/api/admin/classes", json={"name": "1반"}).json()["class_id"]
code = own.post("/api/admin/signup-codes", json={"class_id": cid, "max_uses": 2}).json()["code"]
# 코드로 가입하면 바로 회원 (승인 대기 없음)
r = client.post("/api/members/signup", json={"code": code.lower(), "username": "stud1", "password": "secret1", "name": "김학생"})
assert r.json()["status"] == "approved"
assert client.post("/api/members/signup", json={"code": code, "username": "stud1", "password": "secret1", "name": "x"}).status_code == 409
client.post("/api/members/login", json={"username": "stud1", "password": "secret1"})
# 교수자 비밀번호가 아니면 관리 화면에 들어갈 수 없음
with TestClient(app) as rev:
assert rev.post("/api/admin/login", json={"username": "professor", "password": "test-pass"}).status_code == 401
assert rev.get("/api/admin/members").status_code == 401
# 승인 후: 사례·문제
me = client.get("/api/members/me").json()
assert me["status"] == "approved" and me["class_name"] == "1반" and me["can_view"]
assert client.get(f"/api/terms/{tid}").json()["example"]
qs = client.get(f"/api/members/questions?term={tid}").json()["questions"]
assert qs and all("answer" not in q for q in qs) # 정답은 내려보내지 않음
q = qs[0]
res = client.post("/api/members/answer", json={"token": q["token"], "choice": 0}).json()
assert "correct" in res and "answer" in res
quiz = client.get("/api/members/quiz?galaxy=g6&n=5").json()["questions"]
assert len(quiz) == 5
assert client.get("/api/members/me").json()["attempts"] == 1
# 위조 토큰 거부
assert client.post("/api/members/answer", json={"token": q["token"] + "x", "choice": 0}).status_code == 400
with TestClient(app) as own:
own.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
cls = own.get("/api/admin/classes").json()["items"][0]
assert cls["members"] == 1 and cls["attempts"] == 1
assert own.delete(f"/api/admin/classes/{cls['class_id']}").status_code == 409
def test_manual_question(admin):
_, tid = admin.get("/api/atlas").json(), None
tid = admin.get("/api/atlas").json()["terms"][0]["i"]
r = admin.post("/api/admin/questions", json={"term_id": tid, "qtype": "ox", "prompt": "맞을까요?", "answer": 0, "review_status": "confirmed"})
qid = r.json()["question_id"]
qs = admin.get(f"/api/members/questions?term={tid}").json()["questions"] # 관리자는 볼 수 있음
mq = next(q for q in qs if q["origin"] == "manual")
assert mq["choices"] == ["O", "X"]
assert admin.post("/api/members/answer", json={"token": mq["token"], "choice": 0}).json()["correct"]
assert admin.post("/api/admin/questions", json={"qtype": "mcq", "prompt": "x?", "choices": ["a"], "answer": 0}).status_code == 400
assert admin.delete(f"/api/admin/questions/{qid}").json()["ok"]
def test_signup_code_rules(owner):
c = owner
cid = c.post("/api/admin/classes", json={"name": "B반"}).json()["class_id"]
r = c.post("/api/admin/signup-codes", json={"code": "class-b", "class_id": cid, "max_uses": 1}).json()
assert r["code"] == "CLASS-B"
from fastapi.testclient import TestClient
from server.app import app
with TestClient(app) as s:
r = s.post("/api/members/signup", json={"code": "class-b", "username": "autostud", "password": "secret1", "name": "자동"})
assert r.json()["status"] == "approved"
assert s.post("/api/members/signup", json={"code": "CLASS-B", "username": "late", "password": "secret1", "name": "x"}).status_code == 400 # 횟수 초과
items = c.get("/api/admin/members?status=approved").json()["items"]
u = next(x for x in items if x["username"] == "autostud")
assert u["class_name"] == "B반" and u["signup_code"] == "CLASS-B"
d = c.get(f"/api/admin/members/{u['user_id']}").json()
assert "password_hash" not in d and "by_galaxy" in d
code_id = c.get("/api/admin/signup-codes").json()["items"][0]["code_id"]
c.put(f"/api/admin/signup-codes/{code_id}", json={"class_id": cid, "active": False})
+34
View File
@@ -0,0 +1,34 @@
"""PDF 파싱 도우미 단위 테스트 (PDF 없이 실행)."""
from pipeline.build import norm_key, rejoin_lines, score_sentence, split_name
from pipeline.toc import _split_page
def test_norm_key_merges_spacing_variants():
assert norm_key("지역사회 간호과정") == norm_key("지역사회간호과정")
assert norm_key("Health-Belief Model") == "healthbeliefmodel"
def test_split_name_parentheses():
assert split_name("건강 신념 모형(Health Belief Model; HBM)") == ("건강 신념 모형", ["Health Belief Model", "HBM"])
assert split_name("발생률") == ("발생률", [])
def test_toc_leaders_and_page():
assert _split_page("1. 지역사회의 정의jjjjjjjjjjjj3") == ("1. 지역사회의 정의", 3)
assert _split_page("제1절 지역사회의 이해······ 19") == ("제1절 지역사회의 이해", 19)
assert _split_page("1) 서양 근대 공중보건의 확립_62") == ("1) 서양 근대 공중보건의 확립", 62)
def test_rejoin_lines():
text = "유병률은 특정 시점의 비율을 나타내는 역학적\n지표로, 질병의 부담을 평가하는 데 사용된다.\n2) 발병률\n성과관리는 사업 목표의 달성 정도를 확인하며 지역사회 보건사업에서는 프\n로그램 평가를 한다."
out = rejoin_lines(text).split("\n")
assert out[0] == "유병률은 특정 시점의 비율을 나타내는 역학적 지표로, 질병의 부담을 평가하는 데 사용된다."
assert out[1] == "2) 발병률"
assert "프로그램" in out[2]
def test_definition_scoring():
good = "유병률은 특정 시점에 질병을 가진 사람의 비율을 말한다."
weak = "이 장에서는 유병률과 여러 지표를 함께 다룬다."
assert score_sentence(good, "유병률", norm_key("유병률")) >= 7
assert score_sentence(weak, "유병률", norm_key("유병률")) < 7