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:
@@ -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
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user