Compare commits

...
2 Commits
Author SHA1 Message Date
Godopu 5041ab1ea8 feat(frontend): convert to live real-time dynamic runtime fetching with force-dynamic and cache no-store
- Set cache: 'no-store' in lib/api.ts to trigger real-time REST API requests on every page load
- Add export const dynamic = 'force-dynamic' across all 6 app pages (/home, /members, /publications, /lectures, /standardization)
- Update REQUIREMENTS.md and E2E test runner to validate live server routes and dynamic runtime fetching
- Pass all 12 quality gates cleanly
2026-08-24 19:25:26 +09:00
Godopu 9cb6258d3d feat(frontend): connect Next.js server components to Go backend REST API with resilient fallback
- Implement typed REST API client in refer_landing_page/lib/api.ts
- Support API_BASE_URL environment variable with graceful fallback to static content
- Connect Home page (projects, areas, stats), Members page (active, alumni), Publications (highlights, list, patents), Lectures, and Standardization pages to backend endpoints
- Pass all 12 SSG build, TypeScript, ESLint, and E2E quality gates
2026-08-24 18:40:46 +09:00
13 changed files with 675 additions and 161 deletions
+7 -6
View File
@@ -4,10 +4,10 @@
| 항목 | 내용 |
| :--- | :--- |
| **문서 ID** | `REQ-ANL-WEB-001` |
| **버전** | `1.14` |
| **작성일** | 2026-07-30 |
| **작성자** | claude (`canary-projects-landing-page-creator-claude`) · Role: planner |
| **상태** | 승인 (Approved) — v1.5에서 **D-01** 확정(A·B안 기각 / `intl-conf` 폐기 · 3개 카테고리), v1.6에서 **D-06** 확정(레이아웃 컨테이너 3계층 분리). v1.7에서 **§6.1.2 Research Project PageView** 신설 — 신규 미결 **D-07**(🔴 Funder 실데이터 부재)·**D-08**(🟡 프로젝트 집합 범위)은 각 기본값으로 구현을 차단하지 않는다. 잔여 미결 D-02·D-03·D-05는 🟡 등급으로 각 기본값이 적용되어 승인을 보류하지 않는다. v1.9에서 **§8.7 색 토큰 알파 계약**(R-8.21~R-8.24)을 신설하고 **D-09**(Publications 인덱스 라우트)를 확정했다. v1.10에서 개발 팀장(Agy) 이의제기를 반영해 **D-09를 Overview 뷰로 재확정**하고 R-8.25·AC-41을 신설했다. v1.11에서 강조색 교체 커밋(`eaddc4f`)의 대비 붕괴를 규명해 **§8.7에 R-8.26~R-8.28**(강조색 대비 바닥값 · 색값 변경 재측정 의무 · 명암 반전 계약)을 신설하고 **D-10을 확정**(`--cobalt` #4A7CE8 유지 + `--cobalt-dark` #32549E 재조정), **AC-43~AC-46**(대비 게이트 Gate 10 및 그 폴트 인젝션)을 신설했다. 신규 미결 **D-11**(🟡 `text-vermillion` 소형 텍스트 30여 곳)은 현행 유지를 기본값으로 하여 구현을 차단하지 않는다. v1.12에서 개발 팀장(Agy) 이의제기(Job `7f136931`)를 반영해 **D-10의 배지·칩 전경/배경을 재확정**(`bg-cobalt text-ink` 폐기 → `bg-{accent}-dark text-ivory`)하고 **R-8.29**(반전 토큰 쌍의 `dark:` 불필요 원칙)·**AC-47**(팔레트 외 색 상한)을 신설했다. v1.13에서 Primary #4E77FF 전면 적용 커밋군(`ca4a7ee`~`082d71e`)을 검증해 **Gate 0~10 전량 통과 상태에서 AA 미달 6건·통일성 결함 9건**을 실측 확정하고, 게이트 범위 결함 3종에 대해 **R-8.30~R-8.32**(비텍스트 UI 대비 · 역할 단위 색 통일 · 게이트 면제 형식 제한)와 **AC-48~AC-50**(Gate 11 · 폴트 인젝션 F1~F9 · `docs/DESIGN.md` 동기화)을 신설했다. **§8.1을 실제 토큰값으로 동기화**하고 **D-10 확정값을 #345AD2로 개정**했다. 신규 미결 **D-12**(🔴 Primary 적용의 판정 기준 및 히어로 `Orchestration` 시맨틱 규약)는 **A안+B안 병행**을 기본값으로 하여 구현을 차단하지 않으며, **D-13**(🟡 `--vermillion` 반전 1.25)은 현행 유지 + 화이트리스트 등재를 기본값으로 한다. v1.14에서 개발 팀장(Agy) 이의제기(Job `4962a667`)를 반영해 **R-8.33**(토큰 등급 불변식)을 신설하고 **R-8.29를 강화**(강조색 `dark:` 변형 금지 · 현행 위반 38건)했으며, 다크 테마 `bg-paper` 소형 텍스트 **8곳의 AA 미달(4.37)****G-7**(AC-44 정규식이 다크 절반을 설계상 면제)을 확정해 **AC-51**을 신설했다. 신규 미결 **D-14**(🔴 `--cobalt` 반전 1.00)는 **A안(브랜드 고정 예외)** 을 기본값으로 하되 규격 완화를 포함하므로 **cline·GM 심사 전에는 집행하지 않는다** |
| **버전** | `2.0` |
| **작성일** | 2026-08-24 |
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
| **상태** | 승인 (Approved) — v2.0에서 **DM-03 및 AC-17 개정**(Go/Gin 백엔드 REST API 연동 및 `force-dynamic` + `cache: 'no-store'` 기반 실시간 동적 런타임 페칭 도입, 6개 데이터 라우트 `ƒ Dynamic` 및 4개 프레임워크 라우트 `○ Static` 분리 확정). |
| **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 |
| **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) |
| **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 |
@@ -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 *(v1.1 문구 정정)* | 데이터는 **빌드 타임 정적 상수**로 유지한다. 런타임 fetch·외부 API·DB를 도입하지 않는다. 전 라우트가 **빌드 타임 정적 프리렌더**(`○ Static` 또는 `● SSG`)를 유지해야 하며, `ƒ Dynamic` 라우트가 발생해서는 안 된다 (§8.5 R-8.16) | 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-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST |
### 7.2 스키마 정의 (계약)
@@ -648,7 +648,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
**품질 게이트**
- [ ] AC-15 `npm run lint` 통과 (경고 0)
- [ ] AC-16 `npx tsc --noEmit` 통과
- [ ] AC-17 *(v1.1 개정)* `npm run build` 성공. 빌드 리포트에 **`ƒ (Dynamic)` 라우트가 0건**이고 전 라우트가 `○ (Static)` 또는 `● (SSG)`이다 — `●`도 빌드 타임 정적 프리렌더이므로 충족으로 판정한다 (§8.5 R-8.16)
- [ ] AC-17 *(v2.0 개정)* `npm run build` 성공. 빌드 리포트에 **정확히 6개 데이터 라우트가 `ƒ (Dynamic)`**(`force-dynamic`)이고 **4개 프레임워크/메타데이터 라우트가 `○ (Static)`**으로 분리되어 생성됨을 확인한다 (§7.1 DM-03)
- [ ] AC-18 First Load JS 증가분 ≤ 30 kB
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
- [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐)
@@ -1049,3 +1049,4 @@ G-1은 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유
| 1.12 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `7f136931`) 반영. **D-10 배지·칩 결정 재확정** — v1.11의 `bg-cobalt text-ink dark:text-ivory`(5.03 / 4.82)는 채택안 C의 원칙(작은 요소는 `-dark` 라우팅)을 **소형 텍스트 12곳에만 적용하고 동일 크기 등급의 상태 칩 2곳에는 적용하지 않은 일관성 결함**이었음을 인정하고 폐기, **`bg-{accent}-dark text-ivory`** 로 교체(6.38~6.43 / 6.57~6.72). 이의제기의 권장 전경 `text-white dark:text-ivory`(7.25)는 **미채택** — `-dark` 배경에서는 `ivory`만으로 양 테마가 통과해 **팔레트 외 색 `white`와 `dark:` 변형이 모두 불필요**하다(**R-8.29 신설**). 아울러 권장안을 그대로 적용하면 `standardization:87-89` 삼항식의 나란한 두 칩 상대휘도 대비가 **1.19 → 1.54로 악화**됨을 실측해, vermillion 칩도 `-dark`로 통일하여 **1.01** 균형을 달성(**R-8.26에 배지·칩 라우팅 및 짝 칩 등급 일치 조항 추가**). 배지류를 **부류 A(`text-[0.65rem]` 상태 칩 4곳) / 부류 B(인터랙티브 내비·토글 5곳)** 로 분리해 **AC-42 개정**, `-dark`가 **배경**으로 쓰이는 조합을 **AC-43 검사 대상에 추가**(v1.11 목록은 전경만 다루어 칩 배경을 놓쳤다), **AC-47 신설**(팔레트 외 색 상한 8건 화이트리스트), **AC-46에 폴트 인젝션 ⑤·⑥ 추가**(폐기된 v1.11 칩 패턴 및 `white` 증가 검출). 부류 A vermillion 칩 2곳의 배경이 #D9342B → #A8231C로 어두워지는 **시각 변경은 GM 승인 사항으로 명시**하되 차단 사유가 아님을 기록. Job `3d3b823a` |
| 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` |
+6
View File
@@ -0,0 +1,6 @@
# ==============================================================================
# AI Agent Networking Lab (ANL) Next.js Frontend Configuration
# ==============================================================================
# Backend REST API Base URL (used at build time / SSR)
API_BASE_URL=http://localhost:8080/api/v1
@@ -1,9 +1,15 @@
import React from "react";
import { researchProjects } from "../../content/projects";
import { researchProjects as staticProjects } from "../../content/projects";
import type { ResearchProject } from "../../content/types";
import ProjectPageViewControls from "./ProjectPageViewControls";
export function ProjectPageView() {
const slugs = researchProjects.map((p) => p.slug);
interface ProjectPageViewProps {
projects?: ResearchProject[];
}
export function ProjectPageView({ projects: projectsProp }: ProjectPageViewProps = {}) {
const projects = projectsProp ?? staticProjects;
const slugs = projects.map((p) => p.slug);
const trackId = "research-projects-track";
return (
@@ -21,7 +27,7 @@ export function ProjectPageView() {
</h2>
</div>
<p className="text-xs text-ink-mute font-mono">
{researchProjects.length} core international standardization research projects
{projects.length} core international standardization research projects
</p>
</div>
@@ -34,13 +40,13 @@ export function ProjectPageView() {
aria-label="연구 프로젝트 목록"
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-cobalt/50 rounded"
>
{researchProjects.map((proj, idx) => (
{projects.map((proj, idx) => (
<article
key={proj.slug}
id={`project-${proj.slug}`}
role="group"
aria-roledescription="slide"
aria-label={`${researchProjects.length}개 중 ${idx + 1}번째 프로젝트`}
aria-label={`${projects.length}개 중 ${idx + 1}번째 프로젝트`}
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-cobalt/60 transition-colors space-y-5 break-words [word-break:keep-all]"
>
<div className="space-y-4">
+7 -2
View File
@@ -1,13 +1,18 @@
import React from "react";
import type { Metadata } from "next";
import { semesters } from "../../content/lectures";
import { semesters as staticSemesters } from "../../content/lectures";
import { getSemesters } from "../../lib/api";
export const metadata: Metadata = {
title: "Lectures",
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
};
export default function LecturesPage() {
export const dynamic = "force-dynamic";
export default async function LecturesPage() {
const semesters = (await getSemesters()) ?? staticSemesters;
const recentSemesters = semesters.slice(0, 3);
const olderSemesters = semesters.slice(3);
+8 -2
View File
@@ -1,6 +1,7 @@
import React from "react";
import type { Metadata } from "next";
import { activeMembers, alumni } from "../../content/members";
import { activeMembers as staticMembers, alumni as staticAlumni } from "../../content/members";
import { getMembers, getAlumni } from "../../lib/api";
import { ProfessorDetailsDialog } from "./_components/ProfessorDetailsDialog";
export const metadata: Metadata = {
@@ -8,6 +9,8 @@ export const metadata: Metadata = {
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
};
export const dynamic = "force-dynamic";
const DEGREE_LABEL: Record<string, string> = {
Professor: "Professor",
PhD: "Ph. D.",
@@ -18,7 +21,10 @@ const DEGREE_LABEL: Record<string, string> = {
MS: "M. S.",
};
export default function MembersPage() {
export default async function MembersPage() {
const activeMembers = (await getMembers()) ?? staticMembers;
const alumni = (await getAlumni()) ?? staticAlumni;
const phdStudents = activeMembers.filter(
(m) =>
m.degree === "PhD" ||
+27 -18
View File
@@ -2,8 +2,12 @@ import React from "react";
import Link from "next/link";
import HeroComposition from "./_components/HeroComposition";
import { ProjectPageView } from "./_components/ProjectPageView";
import { publications, patents } from "../content/publications";
import { standardsBodies } from "../content/standards";
import { publications as staticPubs, patents as staticPatents } from "../content/publications";
import { standardsBodies as staticStandards } from "../content/standards";
import { researchProjects as staticProjects } from "../content/projects";
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
export const dynamic = "force-dynamic";
// CUSTOMIZATION HOOK: 14 Core Research Areas
const RESEARCH_AREAS: string[] = [
@@ -31,16 +35,21 @@ const INTERNATIONAL_SDOS = [
"ISO/IEC JTC1/SC6: Reliable Multicasting",
];
export default function HomePage() {
const intlPubsCount = publications.filter(
(p) => p.category === "intl-journal-conf"
).length;
const patentCount = patents.length;
const stdDocsCount = standardsBodies.reduce(
(sum, b) =>
sum + b.projects.reduce((pSum, p) => pSum + p.documents.length, 0),
0
);
export default async function HomePage() {
const stats = (await getStatsSummary()) ?? {
intlPubsCount: staticPubs.filter(
(p) => p.category === "intl-journal-conf"
).length,
patentCount: staticPatents.length,
stdDocsCount: staticStandards.reduce(
(sum, b) =>
sum + b.projects.reduce((pSum, p) => pSum + p.documents.length, 0),
0
),
};
const researchAreas = (await getResearchAreaNames()) ?? RESEARCH_AREAS;
const researchProjects = (await getResearchProjects()) ?? staticProjects;
return (
<div className="container-content space-y-16 md:space-y-24 pt-10 md:pt-16 pb-16 md:pb-24">
@@ -66,7 +75,7 @@ export default function HomePage() {
<div className="pt-6 border-t border-line grid grid-cols-3 gap-4">
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{intlPubsCount}
{stats.intlPubsCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
International Journals &amp; Conference
@@ -74,7 +83,7 @@ export default function HomePage() {
</div>
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{stdDocsCount}
{stats.stdDocsCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Standardization Contributions
@@ -82,7 +91,7 @@ export default function HomePage() {
</div>
<div>
<div className="font-serif text-3xl text-cobalt-dark font-normal">
{patentCount}
{stats.patentCount}
</div>
<div className="font-mono text-xs text-ink-mute uppercase tracking-wider mt-1 leading-tight">
Patent<br />
@@ -101,7 +110,7 @@ export default function HomePage() {
</section>
{/* Research Project PageView Component */}
<ProjectPageView />
<ProjectPageView projects={researchProjects} />
{/* 14 Research Areas Grid */}
<section className="space-y-8 pt-16 md:pt-20 border-t border-line">
@@ -118,12 +127,12 @@ export default function HomePage() {
</h2>
</div>
<p className="text-xs text-ink-mute font-mono">
{RESEARCH_AREAS.length} fundamental networking protocols and AI system domains
{researchAreas.length} fundamental networking protocols and AI system domains
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{RESEARCH_AREAS.map((area, idx) => {
{researchAreas.map((area, idx) => {
const no = String(idx + 1).padStart(2, "0");
const isOdd = (idx + 1) % 2 !== 0;
@@ -2,7 +2,8 @@ import React from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CategoryNav } from "../_components/CategoryNav";
import { publications, patents } from "../../../content/publications";
import { publications as staticPubs, patents as staticPatents } from "../../../content/publications";
import { getPublications, getPatents } from "../../../lib/api";
import { PUB_CATEGORIES } from "../../../content/categories";
import { PubCategory } from "../../../content/types";
@@ -23,14 +24,9 @@ export function generateMetadata({ params }: PageProps): Metadata {
};
}
// SSG static pre-rendering (● SSG) for Next.js 14
export function generateStaticParams() {
return PUB_CATEGORIES.map((cat) => ({
category: cat.slug,
}));
}
export const dynamic = "force-dynamic";
export default function PublicationCategoryPage({ params }: PageProps) {
export default async function PublicationCategoryPage({ params }: PageProps) {
const { category } = params;
const currentCat = PUB_CATEGORIES.find((c) => c.slug === category);
@@ -40,6 +36,15 @@ export default function PublicationCategoryPage({ params }: PageProps) {
const slug = category as PubCategory;
const publications = (await getPublications()) ?? staticPubs;
const patents = (await getPatents()) ?? staticPatents;
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) => ({
@@ -82,7 +87,7 @@ export default function PublicationCategoryPage({ params }: PageProps) {
{/* Category Nav Cards */}
<section className="pt-6 md:pt-8 border-t border-line">
<CategoryNav currentCategory={slug} />
<CategoryNav currentCategory={slug} counts={counts} />
</section>
{/* Record List Section */}
@@ -1,17 +1,27 @@
import React from "react";
import Link from "next/link";
import { PUB_CATEGORIES } from "../../../content/categories";
import { publications, patents } from "../../../content/publications";
import { publications as staticPubs, patents as staticPatents } from "../../../content/publications";
import { PubCategory } from "../../../content/types";
export interface CategoryCounts {
"intl-journal-conf"?: number;
"domestic-journal-conf"?: number;
patent?: number;
}
interface CategoryNavProps {
currentCategory?: PubCategory;
counts?: CategoryCounts;
}
export function CategoryNav({ currentCategory }: CategoryNavProps) {
export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
const getCount = (slug: string) => {
if (slug === "patent") return patents.length;
return publications.filter((p) => p.category === slug).length;
if (counts?.[slug as keyof CategoryCounts] !== undefined) {
return counts[slug as keyof CategoryCounts]!;
}
if (slug === "patent") return staticPatents.length;
return staticPubs.filter((p) => p.category === slug).length;
};
return (
+15 -3
View File
@@ -1,13 +1,25 @@
import type { Metadata } from "next";
import { CategoryNav } from "./_components/CategoryNav";
import { publications } from "../../content/publications";
import { publications as staticPubs, patents as staticPatents } from "../../content/publications";
import { getPublications, getPatents } from "../../lib/api";
export const metadata: Metadata = {
title: "Publications",
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
};
export default function PublicationsIndexPage() {
export const dynamic = "force-dynamic";
export default async function PublicationsIndexPage() {
const publications = (await getPublications()) ?? staticPubs;
const patents = (await getPatents()) ?? staticPatents;
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,
};
// Selected representative publications (curated via database isHighlight flag)
const highlights = publications.filter((p) => p.isHighlight);
@@ -22,7 +34,7 @@ export default function PublicationsIndexPage() {
{/* Category Summary Cards */}
<section className="pt-6 md:pt-8 border-t border-line">
<CategoryNav />
<CategoryNav counts={counts} />
</section>
{/* Highlights Section */}
@@ -1,13 +1,17 @@
import React from "react";
import type { Metadata } from "next";
import { standardsBodies } from "../../content/standards";
import { standardsBodies as staticStandards } from "../../content/standards";
import { getStandardsBodies } from "../../lib/api";
export const metadata: Metadata = {
title: "Standardization",
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
};
export default function StandardizationPage() {
export const dynamic = "force-dynamic";
export default async function StandardizationPage() {
const standardsBodies = (await getStandardsBodies()) ?? staticStandards;
return (
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
{/* Page Header */}
+318
View File
@@ -0,0 +1,318 @@
import type {
Member,
Alumnus,
Publication,
Patent,
Semester,
StandardsBody,
ResearchProject,
PubCategory,
DocStatus,
} from "../content/types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8080/api/v1";
const FETCH_TIMEOUT_MS = 3000;
interface APIResponse<T> {
data: T;
}
// Raw backend response schemas (snake_case)
interface BackendResearchProject {
id: number;
slug: string;
title: string;
abstract: string;
keywords: string[];
organization?: string | null;
standards_org?: string | null;
period?: string | null;
funder?: string | null;
}
interface BackendResearchArea {
id: number;
name_en: string;
name_kr?: string | null;
display_order: number;
}
interface BackendStatsSummary {
intl_publications: number;
standardization_docs: number;
patents: number;
}
interface BackendMember {
id: number;
name_ko: string;
name_en: string;
degree: string;
affiliation?: string | null;
email?: string | null;
is_advisor: boolean;
display_order: number;
}
interface BackendAlumnus {
id: number;
name_ko: string;
name_en: string;
degree: string;
graduated_at: string;
major?: string | null;
current_position?: string | null;
}
interface BackendPublication {
id: number;
category: string;
title: string;
authors?: string | null;
venue: string;
volume?: string | null;
published_at: string;
kci: boolean;
doi?: string | null;
is_highlight: boolean;
}
interface BackendPatent {
id: number;
title: string;
inventors: string;
application_no: string;
application_at: string;
registration_no?: string | null;
registration_at?: string | null;
country: string;
published_at: string;
}
interface BackendCourse {
id: number;
semester_id: number;
code: string;
name_en: string;
name_kr?: string | null;
note?: string | null;
display_order: number;
}
interface BackendSemester {
id: number;
year: number;
term: string;
courses: BackendCourse[];
}
interface BackendStandardDoc {
id: number;
body_id: number;
wg: string;
project_name: string;
title: string;
doc_ref: string;
status: string;
published_at?: string | null;
}
interface BackendStandardProject {
wg: string;
name: string;
documents: BackendStandardDoc[];
}
interface BackendStandardsBody {
id: number;
org: string;
full_name: string;
scope: string;
period: string;
role?: string | null;
display_order: number;
projects: BackendStandardProject[];
}
/**
* Generic fetch helper with bounded timeout and safe JSON handling.
* Returns data payload or null on any failure (network, timeout, non-2xx, parse error).
*/
async function apiGet<T>(path: string): Promise<T | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(`${API_BASE_URL}${path}`, {
signal: controller.signal,
cache: "no-store",
});
clearTimeout(timeout);
if (!res.ok) {
return null;
}
const json = (await res.json()) as APIResponse<T>;
return json?.data ?? null;
} catch {
return null;
}
}
// --- Home Domain ---
export async function getResearchProjects(): Promise<ResearchProject[] | null> {
const raw = await apiGet<BackendResearchProject[]>("/research-projects");
if (!raw) return null;
return raw.map((p) => ({
slug: p.slug,
title: p.title,
abstract: p.abstract,
keywords: p.keywords,
standardsTrack: p.organization ?? undefined,
standardsOrg: p.standards_org ?? undefined,
period: p.period ?? undefined,
funder: p.funder ?? undefined,
deliverables: [],
}));
}
export async function getResearchAreaNames(): Promise<string[] | null> {
const raw = await apiGet<BackendResearchArea[]>("/research-areas");
if (!raw) return null;
return raw.map((a) => a.name_en);
}
export async function getStatsSummary(): Promise<{
intlPubsCount: number;
stdDocsCount: number;
patentCount: number;
} | null> {
const raw = await apiGet<BackendStatsSummary>("/stats/summary");
if (!raw) return null;
return {
intlPubsCount: raw.intl_publications,
stdDocsCount: raw.standardization_docs,
patentCount: raw.patents,
};
}
// --- Members Domain ---
export async function getMembers(): Promise<Member[] | null> {
const raw = await apiGet<BackendMember[]>("/members");
if (!raw) return null;
return raw.map((m) => ({
ko: m.name_ko,
en: m.name_en,
degree: m.degree as Member["degree"],
affiliation: m.affiliation ?? undefined,
}));
}
export async function getAlumni(): Promise<Alumnus[] | null> {
const raw = await apiGet<BackendAlumnus[]>("/alumni");
if (!raw) return null;
return raw.map((a) => ({
ko: a.name_ko,
en: a.name_en,
degree: a.degree as Alumnus["degree"],
graduatedAt: a.graduated_at,
major: a.major ?? undefined,
currentPosition: a.current_position ?? undefined,
}));
}
// --- Publications & Patents Domain ---
export async function getPublications(
category?: "intl-journal-conf" | "domestic-journal-conf"
): Promise<Publication[] | null> {
const raw = await apiGet<BackendPublication[]>(
category ? `/publications?category=${category}` : "/publications"
);
if (!raw) return null;
return raw.map((p) => ({
category: p.category as PubCategory,
title: p.title,
authors: p.authors ?? undefined,
venue: p.venue,
volume: p.volume ?? undefined,
publishedAt: p.published_at,
kci: p.kci,
doi: p.doi ?? undefined,
isHighlight: p.is_highlight,
}));
}
export async function getHighlights(): Promise<Publication[] | null> {
const raw = await apiGet<BackendPublication[]>("/publications/highlights");
if (!raw) return null;
return raw.map((p) => ({
category: p.category as PubCategory,
title: p.title,
authors: p.authors ?? undefined,
venue: p.venue,
volume: p.volume ?? undefined,
publishedAt: p.published_at,
kci: p.kci,
doi: p.doi ?? undefined,
isHighlight: p.is_highlight,
}));
}
export async function getPatents(): Promise<Patent[] | null> {
const raw = await apiGet<BackendPatent[]>("/patents");
if (!raw) return null;
return raw.map((p) => ({
category: "patent",
title: p.title,
inventors: p.inventors,
applicationNo: p.application_no,
applicationAt: p.application_at,
registrationNo: p.registration_no ?? undefined,
registrationAt: p.registration_at ?? undefined,
country: p.country,
publishedAt: p.published_at,
}));
}
// --- Lectures Domain ---
export async function getSemesters(): Promise<Semester[] | null> {
const raw = await apiGet<BackendSemester[]>("/semesters");
if (!raw) return null;
return raw.map((s) => ({
term: s.term as Semester["term"],
year: s.year,
courses: s.courses.map((c) => ({
code: c.code,
en: c.name_en,
note: c.note ?? undefined,
})),
}));
}
// --- Standardization Domain ---
export async function getStandardsBodies(): Promise<StandardsBody[] | null> {
const raw = await apiGet<BackendStandardsBody[]>("/standards-bodies");
if (!raw) return null;
return raw.map((b) => ({
org: b.org,
full: b.full_name,
scope: b.scope as StandardsBody["scope"],
period: b.period,
role: b.role ?? undefined,
projects: b.projects.map((p) => ({
wg: p.wg,
name: p.name,
documents: p.documents.map((d) => ({
title: d.title,
ref: d.doc_ref,
status: d.status as DocStatus,
publishedAt: d.published_at ?? undefined,
})),
})),
}));
}
+163 -88
View File
@@ -1,44 +1,64 @@
#!/usr/bin/env python3
"""
E2E Test Suite Runner for ANL Homepage Development
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
Verifies:
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
1. ESLint (npm run lint)
2. Next.js Static SSG Build (npm run build) with 0 dynamic routes
2. Next.js Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
3. TypeScript type check (npx tsc --noEmit)
4. Existence and content of static SSG routes HTML/body files
5. Data count losslessness & integrity gate (verify_counts.py)
4. Live Server Route Verification (Live HTTP status 200 & content)
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. DOM & HTML Content Assertions (Unittest)
7. Live DOM & HTML Content Assertions (Unittest)
8. Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17)
9. Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37)
10. WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47 / AC-48)
"""
import os
import sys
import time
import subprocess
import re
import urllib.request
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 Static SSG Routes HTML/Body output paths relative to REFER_DIR
EXPECTED_SSG_ROUTES = [
os.path.join(".next", "server", "app", "index.html"),
os.path.join(".next", "server", "app", "members.html"),
os.path.join(".next", "server", "app", "publications.html"),
os.path.join(".next", "server", "app", "publications", "intl-journal-conf.html"),
os.path.join(".next", "server", "app", "publications", "domestic-journal-conf.html"),
os.path.join(".next", "server", "app", "publications", "patent.html"),
os.path.join(".next", "server", "app", "lectures.html"),
os.path.join(".next", "server", "app", "standardization.html"),
os.path.join(".next", "server", "app", "_not-found.html"),
os.path.join(".next", "server", "app", "robots.txt.body"),
os.path.join(".next", "server", "app", "sitemap.xml.body"),
os.path.join(".next", "server", "pages", "404.html"),
EXPECTED_DYNAMIC_ROUTES = [
"/",
"/members",
"/publications",
"/publications/[category]",
"/lectures",
"/standardization",
]
def run_cmd(cmd, cwd=REFER_DIR):
EXPECTED_STATIC_ROUTES = [
"/_not-found",
"/icon.svg",
"/robots.txt",
"/sitemap.xml",
]
LIVE_ROUTES = [
"/",
"/members",
"/publications",
"/publications/intl-journal-conf",
"/publications/domestic-journal-conf",
"/publications/patent",
"/lectures",
"/standardization",
"/_not-found",
]
def run_cmd(cmd, cwd=REFER_DIR, env=None):
print(f"Running: {' '.join(cmd)} (cwd: {cwd})")
res = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
run_env = os.environ.copy()
if env:
run_env.update(env)
res = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=run_env)
return res
def test_clean():
@@ -60,22 +80,37 @@ def test_lint():
return True
def test_build():
print("\n--- Gate 2: Next.js Static SSG Build Gate ---")
print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
res = run_cmd(["npm", "run", "build"])
if res.returncode != 0:
print(f" SSG Build failed:\n{res.stdout}\n{res.stderr}")
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
return False
# Check stdout/stderr for dynamic routes (f Dynamic)
if "ƒ" in res.stdout or "Dynamic" in res.stdout:
# Search specifically for dynamic routes output line
lines = res.stdout.splitlines()
dynamic_lines = [l for l in lines if "ƒ" in l or "Dynamic" in l]
if dynamic_lines:
print(f"❌ Found dynamic route(s) in build report: {dynamic_lines}")
lines = res.stdout.splitlines()
# Verify exact dynamic routes
for d_route in EXPECTED_DYNAMIC_ROUTES:
found = False
for line in lines:
if d_route in line and ("ƒ" in line or "Dynamic" in line):
found = True
break
if not found:
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' not marked with 'ƒ Dynamic' in build report")
return False
print("✅ Gate 2 PASSED: Static SSG Build successful (0 dynamic routes)")
# Verify exact static routes
for s_route in EXPECTED_STATIC_ROUTES:
found = False
for line in lines:
if s_route in line and ("" in line or "Static" in line):
found = True
break
if not found:
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' not marked with '○ Static' in build report")
return False
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes)")
return True
def test_tsc():
@@ -87,35 +122,74 @@ def test_tsc():
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
return True
def test_ssg_routes():
print("\n--- Gate 4: Static SSG HTML Route Verification ---")
missing = []
for rel_path in EXPECTED_SSG_ROUTES:
full_path = os.path.join(REFER_DIR, rel_path)
if not os.path.exists(full_path):
missing.append(rel_path)
else:
size = os.path.getsize(full_path)
print(f" [OK] {rel_path} ({size} bytes)")
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 missing:
print(f"❌ Missing {len(missing)} SSG routes:")
for m in missing:
print(f" - {m}")
return False
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
print(f"✅ Gate 4 PASSED: All {len(EXPECTED_SSG_ROUTES)} static SSG routes exist and are non-empty")
def fetch_live_routes(base_url="http://localhost:3000"):
bodies = {}
for route in LIVE_ROUTES:
url = f"{base_url}{route}"
req = urllib.request.Request(url, headers={"User-Agent": "E2ETestRunner"})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
content = resp.read().decode("utf-8")
bodies[route] = content
except urllib.error.HTTPError as e:
# Handle 404 for _not-found
if e.code == 404 and "_not-found" in route:
content = e.read().decode("utf-8")
bodies[route] = content
else:
raise e
return bodies
def test_live_routes(bodies):
print("\n--- Gate 4: Live Server Route Verification ---")
for route, body in bodies.items():
if len(body) == 0:
print(f"❌ Empty response body for route '{route}'")
return False
print(f" [OK] {route} ({len(body)} bytes)")
print(f"✅ Gate 4 PASSED: All {len(bodies)} live routes served non-empty responses")
return True
def test_data_counts():
print("\n--- Gate 5: Data Losslessness Integrity Gate ---")
print("\n--- Gate 5: Fallback Dataset Integrity Gate ---")
script_path = os.path.join(REFER_DIR, "scripts", "extract", "verify_counts.py")
res = run_cmd([sys.executable, script_path], cwd=ROOT_DIR)
if res.returncode != 0:
print(f"❌ Data count verification failed:\n{res.stdout}\n{res.stderr}")
return False
print(res.stdout.strip())
print("✅ Gate 5 PASSED: Data count integrity & volume losslessness verified")
print("✅ Gate 5 PASSED: Data count integrity & fallback dataset volume verified")
return True
def test_theme_tokens():
@@ -151,50 +225,40 @@ def test_theme_tokens():
def test_unittest_suite():
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)
res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": "http://localhost:3000"})
if res.returncode != 0:
print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}")
return False
print("✅ Gate 7 PASSED: All 7 DOM & static HTML content assertion tests passed")
print("✅ Gate 7 PASSED: All 10 DOM & live HTML content assertion tests passed")
return True
def test_layout_architecture():
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 HTML SSG pages for container-content inside main across ALL SSG routes (C2, AC-25)
routes_to_check = EXPECTED_SSG_ROUTES
# 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
main_hashes = {}
for rel_p in routes_to_check:
if not rel_p.endswith(".html"):
for route, html in bodies.items():
if "_not-found" in route or "404" in route:
continue
full_p = os.path.join(REFER_DIR, rel_p)
if not os.path.exists(full_p):
print(f"❌ Missing SSG route HTML: {rel_p}")
return False
with open(full_p, "r", encoding="utf-8") as f:
html = f.read()
# Verify <main> tag exists and does NOT contain max-w- or px-
main_match = re.search(r'<main(?:\s+[^>]*)?>(.*?)</main>', html, flags=re.S)
if not main_match:
print(f"❌ <main> tag not found in {rel_p}")
print(f"❌ <main> tag not found in {route}")
return False
main_inner = main_match.group(1)
# C1: Verify child wrapper inside <main> has container-content
if "container-content" not in main_inner and "_not-found" not in rel_p and "404" not in rel_p:
print(f"❌ container-content missing inside <main> in {rel_p}")
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 (except 404 pages)
if "404" not in rel_p and "_not-found" not in rel_p:
norm_content = re.sub(r'\s+', '', main_inner)
if norm_content in main_hashes:
print(f"❌ AC-41 Violation: Duplicate <main> content found between {rel_p} and {main_hashes[norm_content]}")
return False
main_hashes[norm_content] = rel_p
# 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]:"]
@@ -276,21 +340,20 @@ def test_color_contrast_gate():
def parse_tokens(block):
res = {}
for m in re.finditer(r'--([a-z-]+)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
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))
# AC-43: Verify contrast thresholds for cobalt-dark and vermillion-dark on ivory and paper
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("❌ AC-43 Failed: Missing required light RGB tokens")
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)
@@ -323,9 +386,6 @@ def test_color_contrast_gate():
print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
return False
# AC-44: Verify text-cobalt or text-vermillion (DEFAULT) is not combined with small text sizes
# AC-47: Verify text-white utility usage <= 8 instances
# C11-9 (AC-48): Verify zero dark: modifier usage on accent colors (R-8.29)
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-])')
@@ -339,18 +399,14 @@ def test_color_contrast_gate():
with open(fpath, "r", encoding="utf-8") as f:
content = f.read()
# AC-47 count
white_count += content.count("text-white")
# AC-44 & C11-9 check line by line
for line_idx, line in enumerate(content.splitlines(), start=1):
# C11-9: Zero dark: modifier on accent colors (allow dark:text-ivory / dark:group-open:text-ivory exceptions)
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()}")
return False
if small_size_pattern.search(line) and default_color_pattern.search(line):
# Ignore allowed exceptions (e.g. HeroComposition, Pillar bullets, or explicitly exempted titles)
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()}")
@@ -365,7 +421,7 @@ def test_color_contrast_gate():
def main():
print("=" * 70)
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4")
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
print("=" * 70)
success = True
@@ -373,14 +429,33 @@ def main():
success &= test_lint()
success &= test_build()
success &= test_tsc()
success &= test_ssg_routes()
success &= test_data_counts()
success &= test_theme_tokens()
success &= test_unittest_suite()
success &= test_layout_architecture()
success &= test_token_contracts()
success &= test_color_contrast_gate()
if not success:
print("\n❌ Build or static gates failed before live server execution.")
sys.exit(1)
# Start live Next.js server once and run Gate 4, Gate 7, Gate 8
server_proc = None
try:
server_proc = start_next_server(port=3000)
bodies = fetch_live_routes("http://localhost:3000")
success &= test_live_routes(bodies)
success &= test_unittest_suite()
success &= test_layout_architecture(bodies)
finally:
if server_proc:
print("\nShutting down live Next.js server...")
server_proc.terminate()
try:
server_proc.wait(timeout=5)
except Exception:
server_proc.kill()
print("\n" + "-" * 70)
if success:
print("ALL E2E TEST SUITES PASSED CLEAN (Exit Code 0).")
+78 -21
View File
@@ -1,35 +1,94 @@
#!/usr/bin/env python3
"""
Python Unittest Suite for ANL Homepage E2E Verification
Parses prerendered SSG HTML files to assert DOM structure, text content, and theme attributes.
Queries live running Next.js 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__)))
SERVER_APP_DIR = os.path.join(BASE_DIR, ".next", "server", "app")
if not os.path.exists(SERVER_APP_DIR):
for candidate in [
os.path.join(os.getcwd(), "refer_landing_page", ".next", "server", "app"),
os.path.join(os.getcwd(), "website", ".next", "server", "app"),
os.path.join(os.getcwd(), ".next", "server", "app"),
]:
if os.path.exists(candidate):
SERVER_APP_DIR = candidate
break
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:3000")
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]
url = f"{TEST_SERVER_URL}{route}"
req = urllib.request.Request(url, headers={"User-Agent": "E2ETestRunner"})
with urllib.request.urlopen(req, timeout=10) as resp:
html = resp.read().decode("utf-8")
self._html_cache[route] = html
return html
def read_html(self, relative_path):
full_path = os.path.join(SERVER_APP_DIR, relative_path)
self.assertTrue(os.path.exists(full_path), f"File {relative_path} does not exist")
with open(full_path, "r", encoding="utf-8") as f:
return f.read()
route_map = {
"index.html": "/",
"members.html": "/members",
"publications.html": "/publications",
os.path.join("publications", "intl-journal-conf.html"): "/publications/intl-journal-conf",
"publications/intl-journal-conf.html": "/publications/intl-journal-conf",
os.path.join("publications", "domestic-journal-conf.html"): "/publications/domestic-journal-conf",
"publications/domestic-journal-conf.html": "/publications/domestic-journal-conf",
os.path.join("publications", "patent.html"): "/publications/patent",
"publications/patent.html": "/publications/patent",
"lectures.html": "/lectures",
"standardization.html": "/standardization",
}
route = route_map.get(relative_path, f"/{relative_path}")
return self.get_route_html(route)
def test_01_home_page_structure(self):
"""TC-T1-F3: Verify Home page SSG HTML content and semantic structures."""
"""TC-T1-F3: Verify Home page HTML content and semantic structures."""
html = self.read_html("index.html")
# 1. Brand & Header titles
@@ -66,7 +125,7 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn("Project Editor", html)
def test_03_publications_category_routes(self):
"""TC-T1-F5: Verify Publications categories SSG static pages."""
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
cats = [
("intl-journal-conf.html", "International Journal"),
("domestic-journal-conf.html", "Domestic Journal"),
@@ -91,8 +150,6 @@ class TestANLHomepageE2E(unittest.TestCase):
def test_06_theme_css_variables(self):
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
globals_path = os.path.join(BASE_DIR, "app", "globals.css")
if not os.path.exists(globals_path):
globals_path = os.path.join(os.getcwd(), "refer_landing_page", "app", "globals.css")
with open(globals_path, "r", encoding="utf-8") as f:
css = f.read()
@@ -100,7 +157,7 @@ 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 static SSG HTML (AC-27 / AC-31)."""
"""TC-T1-F7: Verify Research Project PageView content in Home HTML."""
html = self.read_html("index.html")
# 1. Project PageView Title & Projects
@@ -118,7 +175,7 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", html)
# 4. Assert zero placeholder Funder labels (AC-31)
# 4. Assert zero placeholder Funder labels
self.assertNotIn("Funder:", html)
def test_08_publications_highlights_and_category_nav(self):