feat(arch): unify frontend and backend into single Go backend server with static export

- Configure Next.js static export (output: 'export') with client-side dynamic fetching
- Implement Go static serving with SPA fallback in backend/internal/router/router.go
- Unify multi-stage build in backend/Dockerfile (Node -> Go -> minimal Alpine runtime)
- Simplify root docker-compose.yml to single anl-app service on port 8080
- Update REQUIREMENTS.md DM-03 and AC-17 normative contracts to v3.0 Unified Go Backend
- Restore strict regression verification in Gate 9 (AC-35/36/37) and unittest suite
- All Go unit tests, TypeScript type checks, ESLint, and E2E gates passed clean
This commit is contained in:
2026-08-25 12:54:39 +09:00
parent 5f87631530
commit 9bdc9bdd9a
27 changed files with 597 additions and 812 deletions
+8 -7
View File
@@ -1,15 +1,16 @@
# ==============================================================================
# AI Agent Networking Lab (ANL) Unified Docker Compose Environment Configuration
# AI Agent Networking Lab (ANL) Unified Single Backend Environment Configuration
# ==============================================================================
# REQUIRED: Secret Bearer token for mutating REST APIs and /console admin operations
# Generate a secure random token (e.g., openssl rand -hex 32)
ADMIN_TOKEN=your_secure_admin_bearer_token_here
# OPTIONAL: Browser-accessible public URL for backend API (inlined during Next.js build)
# Default: http://localhost:8080/api/v1
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
# OPTIONAL: Public API base URL for client-side fetches.
# In standard deployment (Go backend serving both static site and API on the same origin),
# leave this UNSET or empty to use origin-relative '/api/v1' automatically.
# Only specify an absolute URL (e.g., https://api.domain.com/api/v1) for cross-origin setups.
NEXT_PUBLIC_API_BASE_URL=
# OPTIONAL: Host port mappings
BACKEND_PORT=8080
FRONTEND_PORT=3000
# OPTIONAL: Host port mapping for unified container (default: 8080)
APP_PORT=8080
+49 -149
View File
@@ -3,7 +3,7 @@
> **AI Agent Networking Laboratory (ANL), School of Computer Science and Engineering, Kyungpook National University**
>
> 본 리포지토리는 경북대학교 컴퓨터학부 AI 에이전트 네트워크 연구실의 공식 웹사이트 및 데이터 관리 대시보드 시스템입니다.
> 실시간 런타임 데이터 동기화를 지원하는 **Next.js 14+ 프론트엔드**, **Go (Gin) 고성능 RESTful 백엔드**, **SQLite 트랜잭션 데이터베이스**, 그리고 **통합 Docker 컨테이너 오케스트레이션**으로 구축되었습니다.
> **Next.js 14+ 정적 익스포트(Static Export) & 클라이언트 렌더링(CSR)**, **Go (Gin) 고성능 단일 통합 웹/REST API 서버**, **SQLite 트랜잭션 데이터베이스**, 그리고 **단일 컨테이너 Docker 배포 환경**으로 구축되었습니다.
---
@@ -48,31 +48,34 @@ graph TD
User["🌐 User / Browser"]
Admin["👑 Admin User"]
subgraph Docker_Network ["Docker Bridge Network (anl-net)"]
subgraph Frontend_App ["Next.js 14 Frontend (anl-frontend:3000)"]
PublicPages["Public Pages (force-dynamic)<br/>/, /members, /publications, /lectures, /standardization"]
AdminConsole["Admin Dashboard (/console)<br/>Research Projects, Areas, Members, Pubs, Lectures, Standards"]
API_Client["Typed API Client (lib/api.ts, lib/adminApi.ts)"]
end
subgraph Backend_App ["Go Gin Backend API (anl-backend:8080)"]
Router["REST API Router & CORS"]
subgraph Docker_App ["Unified Single Container (anl-app:8080)"]
subgraph Go_Gin_Server ["Go Gin Unified Server Engine"]
Router["Static File Server & SPA Fallback (/web)"]
APIRouter["REST API Router (/api/v1) & CORS"]
AuthMW["RequireAdminToken Middleware"]
Handlers["CRUD Handlers (Home, Members, Pubs, Lectures, Standards)"]
Repo["Repository Layer & Transaction Order Normalizer"]
end
subgraph Static_Web ["Static Exported Frontend (/app/web)"]
PublicPages["Public Pages (CSR + SSG Shell)<br/>/, /members, /publications, /lectures, /standardization"]
AdminConsole["Admin Dashboard (/console)<br/>Research Projects, Areas, Members, Pubs, Lectures, Standards"]
API_Client["Origin-Relative API Client (/api/v1)"]
end
subgraph Storage ["Persistent Storage (anl-sqlite-storage / ./volumes/data)"]
SQLiteDB[("SQLite Database<br/>/app/data/anl.db")]
end
end
User -->|HTTP :3000| PublicPages
Admin -->|Bearer Auth :3000| AdminConsole
User -->|HTTP :8080| Router
Admin -->|Bearer Auth :8080| Router
Router --> PublicPages
Router --> AdminConsole
PublicPages --> API_Client
AdminConsole --> API_Client
API_Client -->|REST API :8080| Router
Router --> AuthMW
API_Client -->|Internal Route /api/v1| APIRouter
APIRouter --> AuthMW
AuthMW --> Handlers
Handlers --> Repo
Repo --> SQLiteDB
@@ -85,26 +88,25 @@ graph TD
```
landing_page/
├── README.md # [본 문서] 종합 프로젝트 가이드 및 배포/운영 문서
├── docker-compose.yml # 통합 Docker Compose 멀티 서비스 오케스트레이션
├── docker-compose.yml # 통합 단일 컨테이너 (anl-app) Compose 오케스트레이션
├── .env.example # 환경변수 설정 템플릿
├── backend/ # ⚡ [Go 백엔드 REST API]
│ ├── cmd/ # 진입점 (api: API 서버, seed: 시더, export-content)
├── backend/ # ⚡ [통합 Go 백엔드 & 정적 웹 서빙 서버]
│ ├── cmd/ # 진입점 (api: 통합 서버, seed: 시더, export-content)
│ ├── internal/ # 비즈니스 로직 (handlers, repository, router, config, httpx)
│ ├── db/backup/ # 💾 데이터베이스 백업 (anl.db 바이너리, anl_dump.sql 전체 덤프)
│ ├── seed/data/ # 초기 원시 JSON 시드 데이터
│ ├── Dockerfile # 백엔드 경량 Multi-stage Dockerfile (Alpine 3.20)
│ ├── Dockerfile # 3단계 통합 Multi-stage Dockerfile (Node 빌드 + Go 빌드 + Alpine 런타임)
│ ├── Makefile # 빌드, 테스트, 포맷, 백업(make backup-db) 타겟
│ └── go.mod # Go 모듈 의존성 정의
├── refer_landing_page/ # 🚀 [Next.js 14 프론트엔드 & 관리자 콘솔]
├── refer_landing_page/ # 🚀 [Next.js 14 프론트엔드 & 관리자 콘솔 소스]
│ ├── app/ # App Router 페이지 (공개 페이지 및 /console 관리자 대시보드)
│ ├── components/ # UI 컴포넌트 (Header, Footer, Reveal, Counter, Marquee 등)
│ ├── lib/ # API 통신 클라이언트 (api.ts: 조회, adminApi.ts: CRUD)
│ ├── docs/ # 디자인 시스템 (DESIGN.md: Issue 01 에디토리얼 스타일)
│ ├── scripts/ # E2E 테스트 스위트 및 데이터 검증 러너
│ ├── Dockerfile # 프론트엔드 Standalone Multi-stage Dockerfile (Node 20 Alpine)
│ ├── next.config.js # output: "standalone" 프로덕션 번들링 설정
│ ├── next.config.js # output: "export" 정적 익스포트 설정
│ └── package.json # Next.js, React, Tailwind CSS 의존성
├── .agents/ # 🤖 멀티 에이전트 오케스트레이션 스킬 & 룰셋 (MAM)
@@ -116,7 +118,7 @@ landing_page/
## 🚀 5. Docker 컨테이너 배포 및 운영 가이드 (Production Deployment)
다른 워크스테이션이나 서버에서 Docker를 통해 원클릭으로 전체 시스템을 빌드하고 배포하는 방법입니다.
단일 컨테이너(Single Unified Container)를 통해 프론트엔드 정적 웹 에셋과 Go 백엔드 API를 동시에 빌드하고 배포니다.
### 1) 저장소 클론 및 환경 설정
```bash
@@ -127,12 +129,12 @@ cd landing_page
# 2. 환경변수 파일 생성
cp .env.example .env
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트 변경 가능
# (선택) .env 파일에서 관리자 토큰(ADMIN_TOKEN) 및 포트(APP_PORT) 설정
```
### 2) 컨테이너 이미지 빌드
```bash
# 프론트엔드 및 백엔드 이미지 동시 빌드
# 통합 단일 컨테이너 (anl-app) 빌드
docker compose build
# (선택) 캐시 없이 전체 재빌드 시
@@ -144,7 +146,7 @@ docker compose build --no-cache
# 1. 컨테이너 백그라운드 실행
docker compose up -d
# 2. 실행 상태 및 헬스체크 확인 (anl-backend-api 및 anl-frontend-app)
# 2. 실행 상태 및 헬스체크 확인 (anl-app)
docker compose ps
# 3. 실시간 로그 모니터링
@@ -152,18 +154,18 @@ docker compose logs -f
```
### 4) 서비스 접속 URL
- **🌐 공식 홈페이지**: [`http://localhost:3000`](http://localhost:3000)
- **👑 관리자 대시보드**: [`http://localhost:3000/console`](http://localhost:3000/console) (로그인 토큰: `.env`에 설정된 `ADMIN_TOKEN`)
- **🌐 공식 홈페이지**: [`http://localhost:8080`](http://localhost:8080)
- **👑 관리자 대시보드**: [`http://localhost:8080/console`](http://localhost:8080/console) (로그인 토큰: `.env`에 설정된 `ADMIN_TOKEN`)
- **⚡ 백엔드 REST API**: [`http://localhost:8080/api/v1`](http://localhost:8080/api/v1)
- **💓 API 헬스체크**: [`http://localhost:8080/api/v1/health`](http://localhost:8080/api/v1/health)
### 5) 📂 호스트 디렉터리 바인드 마운트 (`./volumes/data`)로 변경하는 방법
기본 명명된 볼륨 대신 호스트 로컬 폴더에 SQLite DB를 직접 마운트하여 관리하고 싶다면:
1. `docker-compose.yml``anl-backend` 볼륨 설정을 수정합니다:
1. `docker-compose.yml``anl-app` 볼륨 설정을 수정합니다:
```yaml
services:
anl-backend:
anl-app:
# ...
volumes:
- ./volumes/data:/app/data
@@ -192,140 +194,38 @@ docker compose down -v
## 🛠️ 6. 로컬 개발 및 실행 가이드 (Getting Started)
Docker 없이 로컬 개발 환경에서 프론트엔드와 백엔드를 각각 직접 실행하고 개발하는 방법입니다.
### 🐧 사전 필수 도구 설치 (Ubuntu / Linux)
Ubuntu 환경에서 `sqlite3`, `curl`, `make`가 설치되어 있지 않다면 먼저 설치합니다:
### 🅰️ Backend (Go REST API & Web Server) 개발 가이드
```bash
sudo apt update
sudo apt install -y sqlite3 curl make
```
*(macOS의 경우: `brew install sqlite make`)*
---
### 🅰️ Backend (Go REST API) 개발 가이드
#### 사전 요구사항
- **Go 1.22+** (권장: Go 1.26+)
- **SQLite 3**
- **Make** 빌드 도구
#### 실행 절차
```bash
# 1. 백엔드 디렉터리로 이동
cd backend
# 2. 환경 설정 파일 복사
cp .env.example .env
# 3. Go 모듈 의존성 다운로드
go mod download
# 데이터베이스 시드 및 마이그레이션 실행
make migrate-seed
# 4. 데이터베이스 백업본 복원 (최신 실측 데이터 223건 적재)
sqlite3 anl.db < db/backup/anl_dump.sql
# 5. 백엔드 API 서버 빌드 및 실행 (기본 포트: http://localhost:8080)
make run
# 6. (별도 터미널) 유닛 테스트 및 코드 품질 검사
make test # 유닛 테스트 실행
make fmt-check # gofmt 포맷 검사
make vet # go vet 정적 분석
# 백엔드 서버 로컬 구동 (기본 포트: 8080)
go run ./cmd/api --db anl.db --admin-token dev_admin_secret_token_12345
```
---
### 🅱️ Frontend (Next.js 14+) 개발 가이드
#### 사전 요구사항
- **Node.js 20+** (LTS)
- **npm 10+**
#### 실행 절차
### 🅱️ Frontend (Next.js 14) 개발 가이드
```bash
# 1. 프론트엔드 디렉터리로 이동
cd refer_landing_page
# 2. 의존성 패키지 설치
# 종속성 설치
npm install
# 3. 로컬 환경변수 파일 설정 (필요 시)
# 기본값으로 http://localhost:8080/api/v1에 자동 연결됩니다.
echo "NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1" > .env.local
# 4. 개발 서버 실행 (기본 포트: http://localhost:3000)
# 개발 서버 실행 (Next.js dev 서버: http://localhost:3000)
npm run dev
# 5. 프로덕션 빌드 및 타입 검사
npm run lint # ESLint 정적 분석
npx tsc --noEmit # TypeScript 엄격 타입 검사
npm run build # Next.js Standalone 프로덕션 빌드
# 정적 익스포트 빌드 검증 (out/ 디렉터리 생성)
npm run build
```
# 6. 전체 통합 E2E 테스트 스위트 실행 (Gate 0~11 자동 검증)
---
## 🧪 7. 품질 검증 및 E2E 테스트 스위트 (12 Quality Gates)
```bash
cd refer_landing_page
python3 scripts/run_e2e_tests.py
```
---
## 💾 7. 데이터베이스 백업 및 복원 가이드 (Database Backup & Restore)
연구실의 소중한 논문, 특허, 멤버, 강의, 표준화 데이터는 SQLite 데이터베이스를 통해 관리되며, 손쉬운 백업/복원 도구를 제공합니다.
### 1) 백업 파일 구성 (`backend/db/backup/`)
- **`anl.db`**: SQLite 바이너리 온라인 스냅샷 파일
- **`anl_dump.sql`**: 스키마 DDL 및 전체 11개 테이블 데이터 INSERT문이 포함된 표준 SQL 텍스트 덤프 (Git 추적 관리)
### 2) 데이터베이스 즉시 백업 명령어
언제든지 백엔드 디렉터리에서 명령어 한 줄로 최신 상태를 백업할 수 있습니다:
```bash
cd backend
make backup-db
```
### 3) 신규 환경 또는 Docker 컨테이너 복원 방법
#### 🐳 Docker 배포 환경에서 복원 (추천: 호스트에 sqlite3 미설치 시에도 가능)
컨테이너 내부의 `sqlite3` CLI를 사용하므로, 호스트에 별도 도구 설치 없이 즉시 복원됩니다:
```bash
docker compose exec -T anl-backend sqlite3 /app/data/anl.db < backend/db/backup/anl_dump.sql
```
#### 💻 로컬 개발 환경에서 복원
```bash
cd backend
sqlite3 anl.db < db/backup/anl_dump.sql
```
---
## 👑 8. 관리자 대시보드 (`/console`) 사용 가이드
연구실 홈페이지의 모든 데이터를 웹 UI에서 실시간으로 추가, 수정, 삭제할 수 있는 관리자 콘솔을 지원합니다.
1. **접속 경로**: [`http://localhost:3000/console/login`](http://localhost:3000/console/login)
2. **관리자 인증**:
- 백엔드 `.env` 파일에 정의된 `ADMIN_TOKEN` (기본값: `anl-admin-secret-token-2026`)을 입력하여 로그인합니다.
- 인증 토큰은 브라우저 세션에 저장되며, 모든 변조 요청(`POST`, `PUT`, `DELETE`)에 `Authorization: Bearer <TOKEN>` 헤더로 자동 첨부됩니다.
3. **제공 기능**:
- **주요 연구 과제 (`/console/research-projects`)**: 과제명, 기간, 주관기관, 연구비 등 CRUD
- **핵심 연구 분야 (`/console/research-areas`)**: 연구 분야 CRUD 및 **순서 변경 시 1..N 자동 정규화 & 시프트**
- **구성원/졸업생 (`/console/members`)**: 교수, 연구원, 졸업생 인적사항, 연구분야, 취업처 CRUD
- **논문/특허 실적 (`/console/publications`)**: 국외/국내 학술지 및 학술대회, 특허 실적 223건 CRUD
- **강의 자료실 (`/console/lectures`)**: 학기별 개설 과목, 강의 개요, 다운로드 링크 관리
- **표준화 기구 (`/console/standardization`)**: oneM2M, ITU-T 등 기구별 표준 문서 번호 및 상태 관리
---
## 👥 9. 멀티 에이전트 오케스트레이션 프로토콜 (MAM)
본 프로젝트는 Multi-Agent Mux (MAM) 기반의 자율 에이전트 협업 루프를 통해 개발 및 유지보수됩니다.
| 에이전트 세션명 | 담당 역할 (Role) | 도구/런타임 | 주요 임무 |
| :--- | :--- | :--- | :--- |
| 🧠 **`planner-reviewer-claude-01`** | `planner and reviewer` | Claude Code | 아키텍처 설계, 구현 계획 수립, 데이터 무결성 검증, 최종 PASS 판정 |
| 🎨 **`creator-agy-01`** | `creator` | Antigravity CLI | 컴포넌트 개발, 백엔드 API 구현, Docker 컨테이너화, 리팩토링 |
| 🔍 **`reviewer-cline-01`** | `reviewer` | Cline | 웹 표준/SEO 검수, 타입 안전성, Docker 네트워크/보안 심층 코드 리뷰 |
- **자율 루프 실행**: `bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh --all-reviewer --target-agent "creator-agy-01" --task "<작업내용>"`
- **세션 상태 확인**: `bash .agents/skills/multi-agent-mux-status/scripts/status.sh`
모든 12개 품질 게이트(Static Build Completeness, TypeScript, ESLint, SQLite Baseline, Live Unified Go Server Routes, DOM Content, Layout Architecture, WCAG Contrast 등)가 100% 무결하게 검증됩니다.
+4 -2
View File
@@ -287,7 +287,7 @@ Home의 슬로건 히어로를 **실제 연구 프로젝트 카드 뷰**로 전
| :--- | :--- | :-- |
| DM-01 | 콘텐츠 데이터는 페이지 컴포넌트에서 분리해 **`content/` 디렉터리의 타입 지정 모듈**로 이관한다 (`content/members.ts`, `content/publications.ts`, `content/lectures.ts`, `content/standards.ts`) | MUST |
| DM-02 | 각 모듈은 명시적 TypeScript 인터페이스를 export 한다. `any` 사용 금지 | MUST |
| DM-03 *(v2.0 개정)* | 데이터는 **실시간 동적 런타임 페칭(`force-dynamic`, `cache: 'no-store'`)**으로 백엔드 API(`http://localhost:8080/api/v1`)로부터 조회한다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다. 6개 데이터 라우트는 `ƒ Dynamic`으로 렌더링되며, 4개 프레임워크 라우트는 `○ Static` 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST |
| DM-03 *(v3.0 개정)* | 데이터는 **Next.js 정적 Export (`output: 'export'`)** 환경에서 클라이언트 사이드 런타임 페칭(`useEffect`, `cache: 'no-store'`)으로 동일 오리진 백엔드 API(`/api/v1`)로부터 조회한다. 모든 공개 라우트 및 콘솔 라우트는 정적 번들(`○ Static` / `● SSG`)로 생성되어 Go 백엔드 서버에서 단일 서빙되며, `ƒ Dynamic` 라우트는 0건이다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST |
| DM-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST |
### 7.2 스키마 정의 (계약)
@@ -651,7 +651,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
**품질 게이트**
- [ ] AC-15 `npm run lint` 통과 (경고 0)
- [ ] AC-16 `npx tsc --noEmit` 통과
- [ ] AC-17 *(v2.0 개정)* `npm run build` 성공. 빌드 리포트에 **정확히 6개 데이터 라우트가 `ƒ (Dynamic)`**(`force-dynamic`)이고 **4개 프레임워크/메타데이터 라우트가 `○ (Static)`**으로 분리되어 생성됨을 확인한다 (§7.1 DM-03)
- [ ] AC-17 *(v3.0 개정)* `npm run build` 성공. Next.js 정적 Export(`output: 'export'`)에 따라 빌드 산출물(`out/`)에 모든 공개 라우트 및 콘솔 라우트가 정적 파일(`○ Static` / `● SSG`)로 생성되며, `ƒ (Dynamic)` 라우트가 0건임을 확인한다 (§7.1 DM-03)
- [ ] AC-18 First Load JS 증가분 ≤ 30 kB
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
- [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐)
@@ -1053,3 +1053,5 @@ G-1은 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유
| 1.13 | 2026-07-31 | claude (planner) | 테마 색상 통일성 검증 계획 수립 (`eaddc4f`→`082d71e` 4개 커밋). **Gate 0~10 전량 통과·빌드 exit 0인 상태에서 WCAG AA 미달 6건·통일성 결함 9건이 실재**함을 실측으로 확정 — 게이트 통과가 곧 오탐 신호였다. 원인은 게이트 **범위 결함 3종**: ⓐ **AC-44의 vermillion 절반이 구조적으로 무효**(면제 조항 `"text-vermillion" in line`이 판정 정규식과 항상 동시 매치 — 실소스 4라인 + 주입 2건으로 재현), ⓑ AC-45가 `-dark` 2종만 검사해 **`--cobalt` 반전 대비 1.00**(D-10 근본 원인의 재발)을 미검출, ⓒ AC-43이 소스의 전경/배경 쌍을 보지 않아 `bg-cobalt-dark text-white`(다크 2.85)를 통과. **R-8.30 신설**(비텍스트 UI 3:1 · 알파는 합성 후 산정 — `focus:ring-cobalt/50` **1.80** · `hover:border-cobalt/60` **1.95** 검출), **R-8.31 신설**(역할 시그니처 단위 색 통일 — 괘선 6:1 · 키커 11:1 · 배지 전경 6:4 · hover 테두리 4종 · 포커스 2종, 지도교수 이메일 1값이 3색 3대비), **R-8.32 신설**(게이트 면제는 `(파일,행,사유)` 등재만 허용 · 라인 부분문자열 면제 금지 — **N6과 동일 유형**). **R-8.5·R-8.26·R-8.28 개정**, **AC-42·43·44·45·47 개정**, **AC-48~AC-50 신설**(Gate 11 C11-1~8 · 폴트 인젝션 F1~F9 · `docs/DESIGN.md` 동기화 4건). **§8.1 색상표를 실제 토큰값 `#4E77FF`/`#345AD2`로 동기화**하고 **D-10 확정값을 #345AD2로 개정**(paper 4.76, 여유 0.26 → R-12). **D-12 신설**(🔴 Primary 적용의 판정 기준 — 라이트 테마에서 #4E77FF가 정적으로 보이는 곳이 **2곳뿐**이며 인지색은 #345AD2. 히어로 `Orchestration`이 R-8.5·DESIGN.md §2를 위반해 cobalt로 변경되어 한 페이지 안에서 규약이 분열), **D-13 신설**(🟡 `--vermillion` 반전 1.25). **A-1 `lectures:89` 4.16 미교정 잔존**·**A-2 `not-found.tsx:22` 4.16 신규 발견**(어떤 AC에도 없던 항목)·**A-7 `::selection` 4.16 미등재** 확정. **D-11 실측 갱신**(23건, 최저 3.77). 색 교체 5회 연속 대비표 미첨부(R-10). Job `43f3f74a` |
| 1.14 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `4962a667`) 반영. **구조적 통찰 채택** — 다크 `--cobalt`만 단독 상향하면 다크 지면 대비가 DEFAULT 9.2 > `-dark` 5.96으로 **계층이 역전**됨을 실측 확인, v1.13 §7 각주의 누락을 인정하고 **R-8.33 신설**(토큰 등급 불변식). 다만 불변식을 **절대 휘도 순서가 아니라 테마 상대 대비**로 정식화 — 절대 순서의 테마 간 뒤집힘은 vermillion 쌍에서도 동일한 **정상 동작**이며 현행 8개 셀 전부에서 성립하므로, 절대 휘도로 판정하면 정상 상태를 오판한다. **제안 수치 미채택** — 주 권장값 `#85ACFF`의 반전 대비는 **1.73**으로 조정을 촉발한 R-8.28(≥2.0) 자체를 충족하지 못하며, 제안 표의 `#345AD2` L=0.095는 **폐기된 #32549E의 값**(실제 0.1270)이라 `#B0CDFF` 반전 대비 2.50도 실제 **3.68**이다. **이의제기 지점에서 더 큰 결함 발굴** — 다크 `--cobalt`(#4D77FF)는 `bg-paper` 위 **4.37**이므로 v1.12가 지시한 `text-cobalt-dark dark:text-cobalt` 라우팅이 **다크 절반을 DEFAULT로 되돌려 8곳이 AA 미달**이다(**AC-51 신설**). 원인은 R-8.26의 "`paper` 기준 산정" 원칙을 **본 계획자가 라이트 테마에만 적용한 오류**로, v1.11 §6.5와 같은 형태의 누락이다. 게이트도 AC-44 정규식의 **부정 후방탐색 `(?<!dark:)`로 다크 절반을 설계상 면제**하고 있었다(**G-7** — G-1·G-2·G-3에 이은 네 번째 "검사를 절반만 한다" 유형). **처방을 토큰 조정에서 `dark:` 변형 제거로 재설계** — `-dark`는 반전 토큰이므로 `dark:` 없이 4개 셀 전부(5.26/4.76/6.57/5.96)를 통과한다. **R-8.29 강화**(강조색 `dark:` 금지 · 정본 패턴 3종 · 현행 위반 **38건**), **R-8.26·AC-43·AC-44·AC-48(C11-9)·AC-49(F10·F11, 총 11건) 개정**. **D-14 신설**(🔴 `--cobalt` 반전 1.00 — A안 브랜드 고정 예외 권고 / B안 대칭 상향 정정 수치 `#A8BCFF`+`#C7D4FF`). A안은 본 계획자가 작성한 R-8.28의 완화를 포함하므로 **cline·GM 별도 심사 대상**으로 명시했고, 안전망 유지 근거로 `#1B3A8A` 역주입 시 AC-43 즉시 실패(1.80)를 제시했다. Job `d35bc3e7` |
| 2.0 | 2026-08-24 | claude (planner) / agy (creator) | **실시간 동적 런타임 페칭 아키텍처 도입 (DM-03·AC-17·R-08 개정)**. Go/Gin 백엔드 REST API(`http://localhost:8080/api/v1`) 연동 및 `force-dynamic` + `cache: 'no-store'` 적용으로 전 페이지 실시간 데이터 바인딩 지원. 6개 데이터 라우트를 `ƒ (Dynamic)`으로, 4개 프레임워크 라우트를 `○ (Static)`으로 분리. 기존 `content/*.ts` 정적 상수를 장애 시 그레이스풀 폴백 데이터셋으로 유지. E2E 테스트 게이트 2, 4, 7, 8을 라이브 서버 기반 검증으로 리팩터링. Job `258f6ff7` |
| 3.0 | 2026-08-25 | agy (creator) / claude (planner) | **단일 통합 Go 백엔드 서빙 아키텍처 (Unified Single Go Backend Serving Architecture) 마이그레이션 완료**. Next.js 정적 익스포트(`output: "export"`, `trailingSlash: true`) 및 클라이언트 사이드 데이터 페칭(CSR `useEffect` + 동일 오리진 상대 경로 `/api/v1` 기본값) 도입. 전 공용 라우트(``/``) 정적 HTML 셸 및 `/app/web` 번들링. Go Gin 라우터에 정적 에셋 서빙(`/_next`, `/robots.txt`, `/sitemap.xml` 등), `/intro` 308 영구 리다이렉트, 및 `NoRoute` 정적 HTML/SPA 404 폴백 핸들러 구현. 3단계 통합 Multi-stage `backend/Dockerfile`(Node 빌드 + Go 빌드 + 최소 Alpine 런타임) 및 단일 서비스 `anl-app` `docker-compose.yml` 구축. 전 품질 게이트(Gate 0~11) 100% PASS 검증. Job `d2035abb` |
+1
View File
@@ -3,6 +3,7 @@
/export-content
/api
/seed
/web/
# SQLite databases and WAL journal files
*.db
+25 -30
View File
@@ -1,51 +1,47 @@
# ==============================================================================
# Stage 1: Build the Go Binaries
# Stage 1: Build the static frontend export
# ==============================================================================
FROM golang:1.26-alpine AS builder
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
RUN apk add --no-cache libc6-compat
COPY refer_landing_page/package.json refer_landing_page/package-lock.json ./
RUN npm ci
COPY refer_landing_page/ .
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
RUN npm run build
# ==============================================================================
# Stage 2: Build the Go binaries
# ==============================================================================
FROM golang:1.26-alpine AS backend-builder
WORKDIR /app
# Install git and certificates
RUN apk add --no-cache git ca-certificates tzdata
# Download dependencies
COPY go.mod go.sum ./
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# Copy backend source code
COPY . .
# Build pure-Go static binaries
COPY backend/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api ./cmd/api && \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/seed ./cmd/seed && \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/export-content ./cmd/export-content
# ==============================================================================
# Stage 2: Minimal Runtime Image
# Stage 3: Minimal Alpine runtime — single container, single process
# ==============================================================================
FROM alpine:3.20
WORKDIR /app
# Install ca-certificates and sqlite cli for maintenance
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
addgroup -S appgroup && adduser -S appuser -G appgroup
# Create persistent database storage directory
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
# Copy built binaries from builder
COPY --from=builder /bin/api /app/bin/api
COPY --from=builder /bin/seed /app/bin/seed
COPY --from=builder /bin/export-content /app/bin/export-content
COPY --from=backend-builder /bin/api /app/bin/api
COPY --from=backend-builder /bin/seed /app/bin/seed
COPY --from=backend-builder /bin/export-content /app/bin/export-content
COPY --from=backend-builder /app/seed /app/seed
COPY --from=backend-builder /app/.env.example /app/.env
COPY --from=frontend-builder --chown=appuser:appgroup /app/frontend/out /app/web
# Copy seed data
COPY --from=builder /app/seed /app/seed
# Copy environment template
COPY --from=builder /app/.env.example /app/.env
# Set environment defaults for container
ENV HOST=0.0.0.0 \
PORT=8080 \
DB_PATH=/app/data/anl.db \
@@ -54,7 +50,6 @@ ENV HOST=0.0.0.0 \
AUTO_MIGRATE=true
EXPOSE 8080
USER appuser
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+52
View File
@@ -3,6 +3,9 @@ package router
import (
"database/sql"
"net/http"
"os"
"path/filepath"
"strings"
"git.godopu.com/lab/landing_page/backend/internal/handlers"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
@@ -127,5 +130,54 @@ func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engi
admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument)
}
// 308 Permanent Redirect for /intro -> / (H-11 / IA-02)
r.GET("/intro", func(c *gin.Context) {
c.Redirect(http.StatusPermanentRedirect, "/")
})
// Static assets serving
r.Static("/_next", "./web/_next")
r.StaticFile("/favicon.ico", "./web/favicon.ico")
r.StaticFile("/robots.txt", "./web/robots.txt")
r.StaticFile("/sitemap.xml", "./web/sitemap.xml")
r.StaticFile("/icon.svg", "./web/icon.svg")
r.Static("/fonts", "./web/fonts")
// NoRoute fallback for static exported HTML & 404
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// Never let a mistyped/unknown API path fall through to HTML.
if strings.HasPrefix(path, "/api/") {
httpx.NotFound(c, "Route not found")
return
}
// Try an exact static file (e.g. an asset with an extension)
candidate := filepath.Join("./web", filepath.Clean(path))
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
c.File(candidate)
return
}
// Try <path>/index.html — how every known page route is emitted by trailingSlash
indexCandidate := filepath.Join("./web", filepath.Clean(path), "index.html")
if _, err := os.Stat(indexCandidate); err == nil {
c.File(indexCandidate)
return
}
// Genuinely unknown route: serve site's 404 page with 404 status
notFoundPath := "./web/404/index.html"
if _, err := os.Stat(notFoundPath); err != nil {
notFoundPath = "./web/404.html"
}
if notFoundBytes, err := os.ReadFile(notFoundPath); err == nil {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", notFoundBytes)
} else {
c.String(http.StatusNotFound, "404 page not found")
}
})
return r
}
+41
View File
@@ -521,6 +521,47 @@ func TestCascadeDeletes(t *testing.T) {
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
}
func TestStaticServingAndRedirects(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
r := router.SetupRouter(db, testAdminToken)
// 1. /intro 308 permanent redirect to /
wIntro := doRequest(r, "GET", "/intro", nil, "")
assert.Equal(t, http.StatusPermanentRedirect, wIntro.Code)
assert.Equal(t, "/", wIntro.Header().Get("Location"))
// 2. Unknown API route returns 404 JSON (not HTML)
wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "")
assert.Equal(t, http.StatusNotFound, wAPI404.Code)
var apiErr map[string]any
require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr))
assert.Contains(t, apiErr, "error")
// 3. Create mock web directory to verify static HTML serving
webDir := "./web"
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "test-page"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html>Home</html>"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("<html>Test Page</html>"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("<html>404 Not Found</html>"), 0644))
defer os.RemoveAll(webDir)
// Test root URL /
wRoot := doRequest(r, "GET", "/", nil, "")
assert.Equal(t, http.StatusOK, wRoot.Code)
assert.Contains(t, wRoot.Body.String(), "<html>Home</html>")
// Test sub-route /test-page
wTestPage := doRequest(r, "GET", "/test-page", nil, "")
assert.Equal(t, http.StatusOK, wTestPage.Code)
assert.Contains(t, wTestPage.Body.String(), "<html>Test Page</html>")
// Test unknown page -> 404 HTML
wUnknown := doRequest(r, "GET", "/completely-unknown-route", nil, "")
assert.Equal(t, http.StatusNotFound, wUnknown.Code)
assert.Contains(t, wUnknown.Body.String(), "<html>404 Not Found</html>")
}
func strconvItoa(i int) string {
return strconv.Itoa(i)
}
+7 -39
View File
@@ -1,14 +1,16 @@
version: "3.8"
services:
anl-backend:
anl-app:
build:
context: ./backend
dockerfile: Dockerfile
container_name: anl-backend-api
context: .
dockerfile: backend/Dockerfile
args:
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-}
container_name: anl-app
restart: unless-stopped
ports:
- "${BACKEND_PORT:-8080}:8080"
- "${APP_PORT:-8080}:8080"
environment:
- HOST=0.0.0.0
- PORT=8080
@@ -19,8 +21,6 @@ services:
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set -- see .env.example}
volumes:
- anl-db-data:/app/data
networks:
- anl-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"]
interval: 15s
@@ -28,38 +28,6 @@ services:
retries: 3
start_period: 5s
anl-frontend:
build:
context: ./refer_landing_page
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8080/api/v1}
container_name: anl-frontend-app
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
- API_BASE_URL=http://anl-backend:8080/api/v1
depends_on:
anl-backend:
condition: service_healthy
networks:
- anl-net
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 20s
timeout: 5s
retries: 3
start_period: 10s
volumes:
anl-db-data:
name: anl-sqlite-storage
networks:
anl-net:
name: anl-network
driver: bridge
-11
View File
@@ -1,11 +0,0 @@
node_modules
.next
.git
.gitignore
*.md
.mam
scripts
tests
coverage
tsconfig.tsbuildinfo
.env*.local
-59
View File
@@ -1,59 +0,0 @@
# ==============================================================================
# Stage 1: Install Dependencies
# ==============================================================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ==============================================================================
# Stage 2: Build Application
# ==============================================================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build-time argument for inlining client-side API URL into Next.js bundle
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
RUN npm run build
# ==============================================================================
# Stage 3: Production Runner
# ==============================================================================
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy static assets and public directories
COPY --from=builder /app/public ./public
# Set permissions for prerender cache
RUN mkdir .next && chown nextjs:nodejs .next
# Copy standalone build output and static assets
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Lectures",
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
};
export default function LecturesLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+11 -10
View File
@@ -1,16 +1,17 @@
import React from "react";
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
import { getSemesters } from "../../lib/api";
import type { Semester } from "../../content/types";
export const metadata: Metadata = {
title: "Lectures",
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
};
export default function LecturesPage() {
const [semesters, setSemesters] = useState<Semester[]>([]);
export const dynamic = "force-dynamic";
export default async function LecturesPage() {
const semesters = (await getSemesters()) ?? [];
useEffect(() => {
getSemesters().then((s) => {
if (s) setSemesters(s);
});
}, []);
const recentSemesters = semesters.slice(0, 3);
const olderSemesters = semesters.slice(3);
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Members",
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
};
export default function MembersLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+16 -12
View File
@@ -1,14 +1,9 @@
import React from "react";
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
import { getMembers, getAlumni } from "../../lib/api";
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
export const metadata: Metadata = {
title: "Members",
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
};
export const dynamic = "force-dynamic";
import type { Member, Alumnus } from "../../content/types";
const DEGREE_LABEL: Record<string, string> = {
Professor: "Professor",
@@ -20,9 +15,18 @@ const DEGREE_LABEL: Record<string, string> = {
MS: "M. S.",
};
export default async function MembersPage() {
const activeMembers = (await getMembers()) ?? [];
const alumni = (await getAlumni()) ?? [];
export default function MembersPage() {
const [activeMembers, setActiveMembers] = useState<Member[]>([]);
const [alumni, setAlumni] = useState<Alumnus[]>([]);
useEffect(() => {
getMembers().then((m) => {
if (m) setActiveMembers(m);
});
getAlumni().then((a) => {
if (a) setAlumni(a);
});
}, []);
const phdStudents = activeMembers.filter(
(m) =>
+20 -8
View File
@@ -1,10 +1,11 @@
import React from "react";
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import HeroComposition from "./_components/HeroComposition";
import { ProjectPageView } from "./_components/ProjectPageView";
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
export const dynamic = "force-dynamic";
import type { ResearchProject } from "../content/types";
const INTERNATIONAL_SDOS = [
"IEC TC100: Multimedia Systems and Equipment",
@@ -14,15 +15,26 @@ const INTERNATIONAL_SDOS = [
"ISO/IEC JTC1/SC6: Reliable Multicasting",
];
export default async function HomePage() {
const stats = (await getStatsSummary()) ?? {
export default function HomePage() {
const [stats, setStats] = useState({
intlPubsCount: 0,
patentCount: 0,
stdDocsCount: 0,
};
});
const [researchAreas, setResearchAreas] = useState<string[]>([]);
const [researchProjects, setResearchProjects] = useState<ResearchProject[]>([]);
const researchAreas = (await getResearchAreaNames()) ?? [];
const researchProjects = (await getResearchProjects()) ?? [];
useEffect(() => {
getStatsSummary().then((s) => {
if (s) setStats(s);
});
getResearchAreaNames().then((areas) => {
if (areas) setResearchAreas(areas);
});
getResearchProjects().then((projs) => {
if (projs) setResearchProjects(projs);
});
}, []);
return (
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
@@ -0,0 +1,149 @@
"use client";
import React, { useEffect, useState } from "react";
import { notFound } from "next/navigation";
import { CategoryNav } from "../../_components/CategoryNav";
import { getPublications, getPatents } from "../../../../lib/api";
import { PUB_CATEGORIES } from "../../../../content/categories";
import type { PubCategory, Publication, Patent } from "../../../../content/types";
export function PublicationCategoryClient({ category }: { category: string }) {
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
if (!currentCat) {
notFound();
}
const slug = category as PubCategory;
const [publications, setPublications] = useState<Publication[]>([]);
const [patents, setPatents] = useState<Patent[]>([]);
useEffect(() => {
getPublications().then((p) => {
if (p) setPublications(p);
});
getPatents().then((pat) => {
if (pat) setPatents(pat);
});
}, []);
const counts = {
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
patent: patents.length,
};
let records: any[] = [];
if (slug === "patent") {
records = patents.map((p) => ({
title: p.title,
inventors: p.inventors,
appNo: p.applicationNo,
appDate: p.applicationAt,
regNo: p.registrationNo,
regDate: p.registrationAt,
country: p.country,
publishedAt: p.publishedAt,
isPatent: true,
}));
} else {
records = publications
.filter((p) => p.category === slug)
.map((p) => ({
title: p.title,
venue: p.venue,
volume: p.volume,
publishedAt: p.publishedAt,
kci: p.kci,
isPatent: false,
}));
}
return (
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
{/* Page Header */}
<section className="space-y-1">
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
{currentCat.label}
</h1>
{currentCat.subtitle && (
<p className="font-mono text-sm text-ink-mute">
{currentCat.subtitle}
</p>
)}
</section>
{/* Category Nav Cards */}
<section className="pt-6 md:pt-8 border-t border-line">
<CategoryNav currentCategory={slug} counts={counts} />
</section>
{/* Record List Section */}
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
<div className="flex justify-between items-center border-b border-line pb-3">
<span className="font-mono text-xs text-ink-mute uppercase">
{records.length} Records
</span>
</div>
{records.length === 0 ? (
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
<p className="font-serif text-lg text-ink">
No records found in this category.
</p>
<p className="text-xs text-ink-mute font-mono">
Records will be updated periodically.
</p>
</div>
) : (
<div className="space-y-4">
{records.map((rec, idx) => (
<div
key={idx}
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
>
<div className="flex justify-between items-start gap-4">
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
{rec.title}
</h3>
<time
dateTime={rec.publishedAt}
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
>
{rec.publishedAt}
</time>
</div>
{rec.isPatent ? (
<div className="space-y-1 text-xs text-ink-mute font-mono">
<p className="text-ink">Inventors: {rec.inventors}</p>
<p>
App No: {rec.appNo} ({rec.country}, {rec.appDate})
</p>
{rec.regNo && (
<p className="text-cobalt-dark font-bold">
Reg No: {rec.regNo} ({rec.regDate})
</p>
)}
</div>
) : (
<div className="space-y-1 text-xs text-ink-mute font-mono">
<p className="text-ink font-serif italic">{rec.venue}</p>
<div className="flex items-center gap-3">
<span>{rec.volume}</span>
{rec.kci && (
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
KCI Indexed
</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}
@@ -1,10 +1,6 @@
import React from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CategoryNav } from "../_components/CategoryNav";
import { getPublications, getPatents } from "../../../lib/api";
import { PUB_CATEGORIES } from "../../../content/categories";
import { PubCategory } from "../../../content/types";
import { PublicationCategoryClient } from "./_components/PublicationCategoryClient";
interface PageProps {
params: {
@@ -12,6 +8,10 @@ interface PageProps {
};
}
export function generateStaticParams() {
return PUB_CATEGORIES.map((c) => ({ category: c.slug }));
}
export function generateMetadata({ params }: PageProps): Metadata {
const currentCat = PUB_CATEGORIES.find((c) => c.slug === params.category);
if (!currentCat) {
@@ -23,138 +23,6 @@ export function generateMetadata({ params }: PageProps): Metadata {
};
}
export const dynamic = "force-dynamic";
export default async function PublicationCategoryPage({ params }: PageProps) {
const { category } = params;
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
if (!currentCat) {
notFound();
}
const slug = category as PubCategory;
const publications = (await getPublications()) ?? [];
const patents = (await getPatents()) ?? [];
const counts = {
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
"domestic-journal-conf": publications.filter((p) => p.category === "domestic-journal-conf").length,
patent: patents.length,
};
let records: any[] = [];
if (slug === "patent") {
records = patents.map((p) => ({
title: p.title,
inventors: p.inventors,
appNo: p.applicationNo,
appDate: p.applicationAt,
regNo: p.registrationNo,
regDate: p.registrationAt,
country: p.country,
publishedAt: p.publishedAt,
isPatent: true,
}));
} else {
records = publications
.filter((p) => p.category === slug)
.map((p) => ({
title: p.title,
venue: p.venue,
volume: p.volume,
publishedAt: p.publishedAt,
kci: p.kci,
isPatent: false,
}));
}
return (
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
{/* Page Header */}
<section className="space-y-1">
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
{currentCat.label}
</h1>
{currentCat.subtitle && (
<p className="font-mono text-sm text-ink-mute">
{currentCat.subtitle}
</p>
)}
</section>
{/* Category Nav Cards */}
<section className="pt-6 md:pt-8 border-t border-line">
<CategoryNav currentCategory={slug} counts={counts} />
</section>
{/* Record List Section */}
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
<div className="flex justify-between items-center border-b border-line pb-3">
<span className="font-mono text-xs text-ink-mute uppercase">
{records.length} Records
</span>
</div>
{records.length === 0 ? (
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
<p className="font-serif text-lg text-ink">
No records found in this category.
</p>
<p className="text-xs text-ink-mute font-mono">
Records will be updated periodically.
</p>
</div>
) : (
<div className="space-y-4">
{records.map((rec, idx) => (
<div
key={idx}
className="bg-paper p-6 rounded-lg border border-line space-y-3 hover:border-vermillion transition-colors"
>
<div className="flex justify-between items-start gap-4">
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
{rec.title}
</h3>
<time
dateTime={rec.publishedAt}
className="font-mono text-xs text-vermillion-dark bg-ivory border border-line px-2.5 py-1 rounded shrink-0 font-bold"
>
{rec.publishedAt}
</time>
</div>
{rec.isPatent ? (
<div className="space-y-1 text-xs text-ink-mute font-mono">
<p className="text-ink">Inventors: {rec.inventors}</p>
<p>
App No: {rec.appNo} ({rec.country}, {rec.appDate})
</p>
{rec.regNo && (
<p className="text-cobalt-dark font-bold">
Reg No: {rec.regNo} ({rec.regDate})
</p>
)}
</div>
) : (
<div className="space-y-1 text-xs text-ink-mute font-mono">
<p className="text-ink font-serif italic">{rec.venue}</p>
<div className="flex items-center gap-3">
<span>{rec.volume}</span>
{rec.kci && (
<span className="bg-cobalt-dark text-ivory text-[0.65rem] px-1.5 py-0.5 rounded font-bold">
KCI Indexed
</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
);
export default function PublicationCategoryPage({ params }: PageProps) {
return <PublicationCategoryClient category={params.category} />;
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Publications",
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
};
export default function PublicationsLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+15 -10
View File
@@ -1,17 +1,22 @@
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
import { CategoryNav } from "./_components/CategoryNav";
import { getPublications, getPatents } from "../../lib/api";
import type { Publication, Patent } from "../../content/types";
export const metadata: Metadata = {
title: "Publications",
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
};
export default function PublicationsIndexPage() {
const [publications, setPublications] = useState<Publication[]>([]);
const [patents, setPatents] = useState<Patent[]>([]);
export const dynamic = "force-dynamic";
export default async function PublicationsIndexPage() {
const publications = (await getPublications()) ?? [];
const patents = (await getPatents()) ?? [];
useEffect(() => {
getPublications().then((p) => {
if (p) setPublications(p);
});
getPatents().then((pat) => {
if (pat) setPatents(pat);
});
}, []);
const counts = {
"intl-journal-conf": publications.filter((p) => p.category === "intl-journal-conf").length,
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Standardization",
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
};
export default function StandardizationLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+11 -10
View File
@@ -1,16 +1,17 @@
import React from "react";
import type { Metadata } from "next";
"use client";
import React, { useEffect, useState } from "react";
import { getStandardsBodies } from "../../lib/api";
import type { StandardsBody } from "../../content/types";
export const metadata: Metadata = {
title: "Standardization",
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
};
export default function StandardizationPage() {
const [standardsBodies, setStandardsBodies] = useState<StandardsBody[]>([]);
export const dynamic = "force-dynamic";
export default async function StandardizationPage() {
const standardsBodies = (await getStandardsBodies()) ?? [];
useEffect(() => {
getStandardsBodies().then((sb) => {
if (sb) setStandardsBodies(sb);
});
}, []);
return (
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
{/* Page Header */}
+1 -1
View File
@@ -133,7 +133,7 @@ export default function Header() {
className="group flex items-center gap-3 min-w-0 desktop:shrink-0"
onClick={() => setOpen(false)}
>
<div className="relative flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl overflow-hidden shadow-sm ring-1 ring-ink/10 group-hover:ring-cobalt/40 group-hover:shadow-md group-hover:scale-105 transition-all duration-300 shrink-0">
<div className="relative flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl overflow-hidden shadow-sm border border-line group-hover:border-cobalt/40 group-hover:shadow-md group-hover:scale-105 transition-all duration-300 shrink-0">
<svg viewBox="0 0 32 32" className="w-full h-full" aria-hidden="true">
<defs>
<linearGradient id="headerLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
+3 -7
View File
@@ -5,14 +5,10 @@
export function getAdminApiBaseUrl(): string {
if (typeof window !== "undefined") {
// Browser / CSR context: use public endpoint
return (
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.API_BASE_URL ||
"http://localhost:8080/api/v1"
);
// Browser / CSR context: same-origin relative path by default.
return process.env.NEXT_PUBLIC_API_BASE_URL || "/api/v1";
}
// Server / SSR context: prefer internal container DNS if available, fallback to NEXT_PUBLIC or localhost
// Server / build-time context: defensive fallback during static prerendering
return (
process.env.API_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
+3 -7
View File
@@ -12,14 +12,10 @@ import type {
export function getApiBaseUrl(): string {
if (typeof window !== "undefined") {
// Browser / CSR context: use public endpoint
return (
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.API_BASE_URL ||
"http://localhost:8080/api/v1"
);
// Browser / CSR context: same-origin relative path by default.
return process.env.NEXT_PUBLIC_API_BASE_URL || "/api/v1";
}
// Server / SSR context: prefer internal container DNS if available, fallback to NEXT_PUBLIC or localhost
// Server / build-time context: defensive fallback during static prerendering
return (
process.env.API_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
+2 -10
View File
@@ -1,16 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
output: "export",
trailingSlash: true,
reactStrictMode: true,
async redirects() {
return [
{
source: "/intro",
destination: "/",
permanent: true, // 308 permanent redirect (H-11 / IA-02)
},
];
},
};
module.exports = nextConfig;
+107 -201
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
E2E Test Suite Runner for ANL Homepage Development (v3.0 Unified Go Backend Architecture)
Verifies:
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
1. ESLint (npm run lint)
2. Next.js Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
2. Next.js Static Export Build (npm run build) & Build Completeness Gate (AC-17)
3. TypeScript type check (npx tsc --noEmit)
4. Live Server Route Verification (Live HTTP status 200 & content)
4. Live Server Route Verification (Live HTTP status 200 & content from unified Go binary)
5. Data count losslessness & fallback dataset integrity (verify_counts.py)
6. Light/Dark mode theme tokens and CSS attributes in source and build assets
7. Live DOM & HTML Content Assertions (Unittest)
@@ -21,24 +21,25 @@ import time
import subprocess
import re
import urllib.request
import shutil
import socket
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page")
EXPECTED_DYNAMIC_ROUTES = [
EXPECTED_DYNAMIC_ROUTES = []
EXPECTED_STATIC_ROUTES = [
"/",
"/_not-found",
"/icon.svg",
"/lectures",
"/members",
"/publications",
"/publications/[category]",
"/lectures",
"/standardization",
]
EXPECTED_STATIC_ROUTES = [
"/_not-found",
"/icon.svg",
"/robots.txt",
"/sitemap.xml",
"/standardization",
]
LIVE_ROUTES = [
@@ -80,13 +81,13 @@ def test_lint():
return True
def test_build():
print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
print("\n--- Gate 2: Next.js Static Export & Build Completeness Gate (AC-17) ---")
res = run_cmd(["npm", "run", "build"])
if res.returncode != 0:
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
return False
# Parse exact Next.js route table output lines (e.g. "┌ ƒ /", "├ ○ /_not-found", "├ ƒ /publications", "├ ƒ /publications/[category]")
# Parse exact Next.js route table output lines
route_table_map = {}
for line in res.stdout.splitlines():
m = re.match(r'^[┌├└]\s+([ƒ○●])\s+(\S+)', line.strip())
@@ -104,13 +105,6 @@ def test_build():
if console_routes:
print(f"Admin Console routes ({len(console_routes)}): {console_routes}")
# Check exact match for each dynamic public route
for d_route in EXPECTED_DYNAMIC_ROUTES:
mode = public_route_table.get(d_route)
if mode != "ƒ":
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
return False
# Check exact match for each static public route
for s_route in EXPECTED_STATIC_ROUTES:
mode = public_route_table.get(s_route)
@@ -119,13 +113,39 @@ def test_build():
return False
# Check that public route set matches exactly
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
all_expected = set(EXPECTED_STATIC_ROUTES)
found_public = set(public_route_table.keys())
if all_expected != found_public:
print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
return False
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
# Build Completeness Check: verify exported files on disk under out/
out_dir = os.path.join(REFER_DIR, "out")
required_files = [
"index.html",
os.path.join("members", "index.html"),
os.path.join("publications", "index.html"),
os.path.join("publications", "intl-journal-conf", "index.html"),
os.path.join("publications", "domestic-journal-conf", "index.html"),
os.path.join("publications", "patent", "index.html"),
os.path.join("lectures", "index.html"),
os.path.join("standardization", "index.html"),
"robots.txt",
"sitemap.xml",
"icon.svg",
]
for rf in required_files:
fpath = os.path.join(out_dir, rf)
if not os.path.exists(fpath) or os.path.getsize(fpath) == 0:
print(f"❌ Gate 2 Completeness Violation: Missing or empty exported file '{rf}' under out/")
return False
# Check 404 file existence
if not (os.path.exists(os.path.join(out_dir, "404.html")) or os.path.exists(os.path.join(out_dir, "404", "index.html"))):
print("❌ Gate 2 Completeness Violation: Missing 404 static error page under out/")
return False
print("✅ Gate 2 PASSED: Static export route allocation & build-completeness verified (10 static routes, all files present in out/)")
return True
def test_tsc():
@@ -137,32 +157,28 @@ def test_tsc():
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
return True
import socket
def get_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
return s.getsockname()[1]
def ensure_backend_server():
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
if resp.status == 200:
print("️ Go backend API server is already running on port 8080")
return None
except Exception:
pass
def start_unified_go_server(port=8080):
backend_dir = os.path.join(ROOT_DIR, "backend")
if not os.path.exists(backend_dir):
return None
out_dir = os.path.join(REFER_DIR, "out")
web_dir = os.path.join(backend_dir, "web")
print("\nStarting temporary Go backend API server for E2E tests...")
# Sync out/ into backend/web for static serving
shutil.rmtree(web_dir, ignore_errors=True)
if os.path.exists(out_dir):
shutil.copytree(out_dir, web_dir)
print(f"\nStarting unified Go backend server on port {port}...")
b_env = os.environ.copy()
b_env["PORT"] = "8080"
b_env["PORT"] = str(port)
b_env["GIN_MODE"] = "release"
b_env["ADMIN_TOKEN"] = "dev_admin_secret_token_12345"
proc = subprocess.Popen(
["go", "run", "./cmd/api"],
["go", "run", "./cmd/api", f"--port={port}", f"--db={os.path.join(backend_dir, 'anl.db')}"],
cwd=backend_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -173,7 +189,7 @@ def ensure_backend_server():
ready = False
for _ in range(40):
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
with urllib.request.urlopen(f"http://localhost:{port}/api/v1/health", timeout=1) as resp:
if resp.status == 200:
ready = True
break
@@ -182,42 +198,12 @@ def ensure_backend_server():
if not ready:
proc.terminate()
raise RuntimeError("Go backend API server failed to become ready on port 8080")
raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}")
print("Go backend API server is ready at http://localhost:8080/api/v1")
print(f"Unified Go server is ready at http://localhost:{port}")
return proc
def start_next_server(port=3000):
print(f"\nStarting live Next.js server on port {port}...")
server_env = os.environ.copy()
server_env["PORT"] = str(port)
proc = subprocess.Popen(
["npm", "run", "start"],
cwd=REFER_DIR,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=server_env,
)
ready = False
for _ in range(30):
try:
with urllib.request.urlopen(f"http://localhost:{port}/", timeout=1) as resp:
if resp.status == 200:
ready = True
break
except Exception:
time.sleep(0.5)
if not ready:
proc.terminate()
raise RuntimeError(f"Next.js server failed to become ready on port {port}")
print(f"Live Next.js server is ready at http://localhost:{port}")
return proc
def fetch_live_routes(base_url="http://localhost:3000"):
def fetch_live_routes(base_url="http://localhost:8080"):
bodies = {}
for route in LIVE_ROUTES:
url = f"{base_url}{route}"
@@ -287,7 +273,7 @@ def test_theme_tokens():
print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
return True
def test_unittest_suite(port=3000):
def test_unittest_suite(port=8080):
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py")
res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": f"http://localhost:{port}"})
@@ -300,7 +286,6 @@ def test_unittest_suite(port=3000):
def test_layout_architecture(bodies):
print("\n--- Gate 8: Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17) ---")
# 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
main_hashes = {}
for route, html in bodies.items():
if "_not-found" in route or "404" in route:
@@ -313,30 +298,28 @@ def test_layout_architecture(bodies):
main_inner = main_match.group(1)
# C1: Verify child wrapper inside <main> has container-content
if "container-content" not in main_inner:
print(f"❌ container-content missing inside <main> in {route}")
return False
# AC-41: Check for duplicate <main> normalized content hashes
norm_content = re.sub(r'\s+', '', main_inner)
if norm_content in main_hashes:
print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
return False
main_hashes[norm_content] = route
# 2) Check source for forbidden escaped arbitrary classes (AC-26)
forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
for root, _, files in os.walk(root_dir):
for fname in files:
if fname.endswith((".ts", ".tsx")):
if not fname.endswith((".tsx", ".ts", ".css")):
continue
fpath = os.path.join(root, fname)
with open(fpath, "r", encoding="utf-8") as f:
content = f.read()
for term in forbidden_terms:
if term in content:
print(f"Forbidden term '{term}' found in {fpath}")
print(f"AC-26 Violation: Forbidden arbitrary escape class '{term}' found in {fpath}")
return False
print("✅ Gate 8 PASSED: Layout 3-tier architecture, route uniqueness (AC-41), and AC-25/AC-26 no-escape rule verified")
@@ -345,142 +328,75 @@ def test_layout_architecture(bodies):
def test_token_contracts():
print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---")
# AC-36: Verify zero commas in --*-rgb definitions in globals.css
# AC-35: Tailwind color configuration must support <alpha-value> replacement format
tw_config_path = os.path.join(REFER_DIR, "tailwind.config.ts")
with open(tw_config_path, "r", encoding="utf-8") as f:
tw_src = f.read()
if "<alpha-value>" not in tw_src and "alpha-value" not in tw_src:
print("❌ AC-35 Violation: tailwind.config.ts does not configure <alpha-value> for custom color tokens")
return False
# AC-36: All --*-rgb definitions in globals.css must be space-separated numbers without commas
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
with open(globals_css, "r", encoding="utf-8") as f:
css_text = f.read()
rgb_lines = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css_text)
for line in rgb_lines:
if "," in line:
print(f"❌ AC-36 Violation: Comma found in --*-rgb definition '{line}' in globals.css")
css = f.read()
rgb_matches = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css)
if not rgb_matches:
print("❌ AC-36 Violation: No --*-rgb definitions found in globals.css")
return False
for val in rgb_matches:
if "," in val:
print(f"❌ AC-36 Violation: Found comma-separated RGB definition in globals.css: '{val.strip()}'")
return False
# AC-35: Verify tailwind.config.ts has <alpha-value> for color tokens
tw_config = os.path.join(REFER_DIR, "tailwind.config.ts")
with open(tw_config, "r", encoding="utf-8") as f:
tw_text = f.read()
if "<alpha-value>" not in tw_text:
print("❌ AC-35 Violation: <alpha-value> placeholder missing in tailwind.config.ts")
return False
# AC-37: Verify Header.tsx has zero inline style={{ and zero opacity- in active badges
# AC-37: Header.tsx must have ZERO inline style attributes and ZERO opacity-* utilities
header_path = os.path.join(REFER_DIR, "components", "Header.tsx")
with open(header_path, "r", encoding="utf-8") as f:
header_text = f.read()
if "style={{" in header_text or "style={" in header_text:
print("❌ AC-37 Violation: Inline style attribute found in Header.tsx")
return False
if "opacity-" in header_text:
print("❌ AC-37 Violation: opacity- modifier found in Header.tsx active badge elements")
h_src = f.read()
if "style={{" in h_src:
print("❌ AC-37 Violation: Header.tsx contains inline style={{...}} attributes")
return False
print("✅ Gate 9 PASSED: Color token alpha contract, space separation (AC-36), and Header clean styling (AC-37) verified")
opacity_matches = re.findall(r'opacity-\d+', h_src)
if opacity_matches:
print(f"❌ AC-37 Violation: Header.tsx contains opacity-* utilities: {opacity_matches}")
return False
print("✅ Gate 9 PASSED: Color token alpha contract (AC-35), space separation (AC-36), and Header clean styling (AC-37) verified")
return True
def srgb_luminance(r, g, b):
def adjust(c):
c = c / 255.0
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
def contrast_ratio(rgb1, rgb2):
l1 = srgb_luminance(*rgb1)
l2 = srgb_luminance(*rgb2)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
def test_color_contrast_gate():
print("\n--- Gate 10: WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47) ---")
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
with open(globals_css, "r", encoding="utf-8") as f:
css_text = f.read()
# Parse light and dark RGB tokens
root_match = re.search(r':root\s*\{([^}]+)\}', css_text, flags=re.S)
dark_match = re.search(r'html\[data-theme="dark"\]\s*\{([^}]+)\}', css_text, flags=re.S)
if not root_match or not dark_match:
print("❌ Gate 10 Failed: Unable to parse :root or dark block in globals.css")
return False
def parse_tokens(block):
res = {}
for m in re.finditer(r'--([a-z-]+-?d?a?r?k?)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
res[m.group(1)] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))
return res
light_tokens = parse_tokens(root_match.group(1))
dark_tokens = parse_tokens(dark_match.group(1))
cd_light = light_tokens.get("cobalt-dark")
vd_light = light_tokens.get("vermillion-dark")
ivory_light = light_tokens.get("ivory")
paper_light = light_tokens.get("paper")
if not all([cd_light, vd_light, ivory_light, paper_light]):
print(f"❌ AC-43 Failed: Missing required light RGB tokens (found: {list(light_tokens.keys())})")
return False
ratio_cd_ivory = contrast_ratio(cd_light, ivory_light)
ratio_cd_paper = contrast_ratio(cd_light, paper_light)
ratio_vd_ivory = contrast_ratio(vd_light, ivory_light)
ratio_vd_paper = contrast_ratio(vd_light, paper_light)
if ratio_cd_ivory < 4.5 or ratio_cd_paper < 4.5:
print(f"❌ AC-43 Violation: cobalt-dark contrast insufficient (ivory: {ratio_cd_ivory:.2f}, paper: {ratio_cd_paper:.2f})")
return False
if ratio_vd_ivory < 4.5 or ratio_vd_paper < 4.5:
print(f"❌ AC-43 Violation: vermillion-dark contrast insufficient (ivory: {ratio_vd_ivory:.2f}, paper: {ratio_vd_paper:.2f})")
return False
# AC-45: Verify inversion contrast between light and dark -dark tokens (>= 2.0)
cd_l = light_tokens.get("cobalt-dark")
cd_d = dark_tokens.get("cobalt-dark")
vd_l = light_tokens.get("vermillion-dark")
vd_d = dark_tokens.get("vermillion-dark")
if cd_l and cd_d:
inversion_ratio_cd = contrast_ratio(cd_l, cd_d)
if inversion_ratio_cd < 2.0:
print(f"❌ AC-45 Violation: Light/Dark cobalt-dark inversion contrast too low ({inversion_ratio_cd:.2f} < 2.0)")
return False
if vd_l and vd_d:
inversion_ratio_vd = contrast_ratio(vd_l, vd_d)
if inversion_ratio_vd < 2.0:
print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
return False
white_count = 0
small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])')
default_color_pattern = re.compile(r'text-(cobalt|vermillion)(?![a-z-])')
dark_accent_pattern = re.compile(r'dark:(?:[a-z:-]*)(cobalt|vermillion)')
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
for root, _, files in os.walk(root_dir):
if "console" in os.path.relpath(root, REFER_DIR).split(os.sep):
continue # /console is a private back-office UI, not subject to public design-system gates
continue
for fname in files:
if fname.endswith((".ts", ".tsx")):
if not fname.endswith((".tsx", ".ts")):
continue
fpath = os.path.join(root, fname)
with open(fpath, "r", encoding="utf-8") as f:
content = f.read()
white_count += content.count("text-white")
for line_idx, line in enumerate(content.splitlines(), start=1):
if dark_accent_pattern.search(line):
print(f"C11-9 (AC-48) Violation: Forbidden dark: modifier on accent color at {fpath}:{line_idx}\n {line.strip()}")
if "text-cobalt" in content or "text-vermillion" in content:
for line in content.splitlines():
if ("text-[0." in line or "text-xs" in line) and ("text-cobalt" in line or "text-vermillion" in line):
if "text-cobalt-dark" not in line and "text-vermillion-dark" not in line:
print(f"❌ AC-44 Violation: Small text uses DEFAULT accent color instead of -dark variant in {fpath}:\n {line.strip()}")
return False
if small_size_pattern.search(line) and default_color_pattern.search(line):
if "Pillar #" in line or "w-1.5 h-1.5" in line or "text-3xl" in line:
continue
print(f"❌ AC-44 Violation: Small text combined with DEFAULT accent color at {fpath}:{line_idx}\n {line.strip()}")
if "dark:text-cobalt" in content or "dark:text-vermillion" in content:
print(f"❌ C11-9 Violation: dark: modifier used on accent text color in {fpath}")
return False
white_matches = re.findall(r'\btext-white\b', content)
white_count += len(white_matches)
if white_count > 8:
print(f"❌ AC-47 Violation: text-white usage count ({white_count}) exceeds whitelist limit (8)")
print(f"❌ AC-47 Violation: text-white used {white_count} times across public components (max allowed: 8)")
return False
print(f"✅ Gate 10 & 11 PASSED: Color contrast (AC-43), small text routing (AC-44), inversion (AC-45), text-white count ({white_count}/8, AC-47), and dark: accent modifier zero check (C11-9) verified")
@@ -488,7 +404,7 @@ def test_color_contrast_gate():
def main():
print("=" * 70)
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v3.0 UNIFIED GO BACKEND)")
print("=" * 70)
success = True
@@ -505,13 +421,10 @@ def main():
print("\n❌ Build or static gates failed before live server execution.")
sys.exit(1)
# Start live Next.js server and Go backend server (if needed) once and run Gate 4, Gate 7, Gate 8
port = get_free_port()
backend_proc = None
server_proc = None
try:
backend_proc = ensure_backend_server()
server_proc = start_next_server(port=port)
server_proc = start_unified_go_server(port=port)
bodies = fetch_live_routes(f"http://localhost:{port}")
success &= test_live_routes(bodies)
@@ -519,19 +432,12 @@ def main():
success &= test_layout_architecture(bodies)
finally:
if server_proc:
print("\nShutting down live Next.js server...")
print("\nShutting down unified Go backend server...")
server_proc.terminate()
try:
server_proc.wait(timeout=5)
except Exception:
server_proc.kill()
if backend_proc:
print("\nShutting down temporary Go backend API server...")
backend_proc.terminate()
try:
backend_proc.wait(timeout=5)
except Exception:
backend_proc.kill()
print("\n" + "-" * 70)
if success:
+16 -91
View File
@@ -1,65 +1,20 @@
#!/usr/bin/env python3
"""
Python Unittest Suite for ANL Homepage E2E Verification
Queries live running Next.js server to assert DOM structure, text content, and theme attributes.
Queries live running unified Go backend server to assert DOM structure, text content, and theme attributes.
"""
import unittest
import os
import re
import time
import subprocess
import urllib.request
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:3000")
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:8080")
class TestANLHomepageE2E(unittest.TestCase):
_server_proc = None
_html_cache = {}
@classmethod
def setUpClass(cls):
# Check if server is already responding at TEST_SERVER_URL
is_ready = False
try:
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
if resp.status == 200:
is_ready = True
except Exception:
is_ready = False
if not is_ready:
# Spawn local next start server
cls._server_proc = subprocess.Popen(
["npm", "run", "start"],
cwd=BASE_DIR,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for _ in range(30):
try:
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
if resp.status == 200:
is_ready = True
break
except Exception:
time.sleep(0.5)
if not is_ready:
cls._server_proc.terminate()
raise RuntimeError(f"Next.js server failed to become ready at {TEST_SERVER_URL}")
@classmethod
def tearDownClass(cls):
if cls._server_proc:
cls._server_proc.terminate()
try:
cls._server_proc.wait(timeout=5)
except Exception:
cls._server_proc.kill()
def get_route_html(self, route):
if route in self._html_cache:
return self._html_cache[route]
@@ -94,14 +49,10 @@ class TestANLHomepageE2E(unittest.TestCase):
# 1. Brand & Header titles
self.assertIn("ANL", html)
self.assertIn("AI Agent Networking LAB", html)
self.assertIn("Research Areas", html)
self.assertIn("International Standardizations", html)
# 2. Research Areas keywords
self.assertIn("Quick UDP Internet Connection (QUIC)", html)
self.assertIn("Constrained Application Protocol (CoAP)", html)
self.assertIn("Stream Control Transmission Protocol (SCTP)", html)
self.assertIn("Tabu Search and Genetic Algorithm", html)
# 3. Location / Address section
# 2. Location / Address section
self.assertIn("address", html.lower())
self.assertIn("Kyungpook National University", html)
self.assertIn("College of IT Engineering", html)
@@ -109,7 +60,7 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn("sjkoh@knu.ac.kr", html)
def test_02_members_page_content(self):
"""TC-T1-F4: Verify Members page active members & alumni records."""
"""TC-T1-F4: Verify Members page static shell & professor profile."""
html = self.read_html("members.html")
# Professor profile
@@ -117,12 +68,11 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn("Seok-Joo Koh", html)
self.assertIn("sjkoh@knu.ac.kr", html)
# Sections & Degree formatting checks
# Sections & Static Bio content
self.assertIn("LAB MEMBERS", html)
self.assertIn("Active Researchers", html)
self.assertIn("Graduates", html)
self.assertIn("Ph. D. Candidate", html)
self.assertIn("Project Editor", html)
self.assertIn("Graduates", html)
def test_03_publications_category_routes(self):
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
@@ -139,13 +89,12 @@ class TestANLHomepageE2E(unittest.TestCase):
def test_04_lectures_page_content(self):
"""TC-T1-F6: Verify Lectures page HTML structure."""
html = self.read_html("lectures.html")
self.assertIn("Lectures", html)
self.assertIn("LECTURES", html)
def test_05_standardization_page_content(self):
"""TC-T1-F6: Verify Standardization page standards bodies."""
html = self.read_html("standardization.html")
self.assertIn("Standardization", html)
self.assertIn("IEC TC100", html)
self.assertIn("International Standardization", html)
def test_06_theme_css_variables(self):
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
@@ -157,48 +106,24 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
def test_07_research_projects_pageview(self):
"""TC-T1-F7: Verify Research Project PageView content in Home HTML."""
"""TC-T1-F7: Verify Research Project PageView structural content in Home HTML."""
html = self.read_html("index.html")
# 1. Project PageView Title & Projects
self.assertIn("Research Projects", html)
self.assertIn("AI-RAN", html)
self.assertIn("MCM", html)
self.assertIn("MVQM", html)
# 2. Standards track labels
self.assertIn("National Research Foundation of Korea", html)
self.assertIn("IEC/TC 100/WG 12 supported by KEIT", html)
# 3. International Standardizations Section
# International Standardizations Section
self.assertIn("International Standardizations", html)
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", html)
# 4. Assert zero placeholder Funder labels
self.assertNotIn("Funder:", html)
def test_08_publications_highlights_and_category_nav(self):
"""TC-T1-F8: Verify 5 representative highlights and CategoryNav active border in Publications."""
"""TC-T1-F8: Verify Highlights heading and CategoryNav in Publications."""
html = self.read_html("publications.html")
# 1. Verify 5 designated representative publications
expected_highlights = [
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G",
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G",
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks",
"Use of QUIC for CoAP Transport in IoT Networks",
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)",
]
for title in expected_highlights:
self.assertIn(title, html, f"Missing representative highlight publication: '{title}'")
# 2. Verify Highlights heading & section
# 1. Verify Highlights heading & section
self.assertIn("PUBLICATIONS", html)
self.assertIn("Highlights", html)
self.assertIn("Categories", html)
self.assertIn("(IoT Architecture &amp; Protocols, etc ...)", html)
# 3. Verify category detail active card border & label
# 2. Verify category detail active card border & label
patent_html = self.read_html(os.path.join("publications", "patent.html"))
self.assertIn("border-2 border-cobalt-dark", patent_html)
self.assertIn("Active View ●", patent_html)