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:
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.mam
|
||||
scripts
|
||||
tests
|
||||
coverage
|
||||
tsconfig.tsbuildinfo
|
||||
.env*.local
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+149
@@ -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}</>;
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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%">
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")):
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
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")):
|
||||
fpath = os.path.join(root, fname)
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
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()
|
||||
|
||||
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 "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_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()}")
|
||||
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()}")
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 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)
|
||||
self.assertIn("Research Areas", html)
|
||||
self.assertIn("International Standardizations", 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 & 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)
|
||||
|
||||
Reference in New Issue
Block a user