This commit is contained in:
1
frontend/.nuxtrc
Normal file
1
frontend/.nuxtrc
Normal file
@@ -0,0 +1 @@
|
||||
setups.@nuxt/test-utils="3.23.0"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<template>
|
||||
<div class="app-root" :class="{ 'zh': locale === 'zh' }" :data-lang="locale">
|
||||
<AnimatedBackground />
|
||||
<NuxtRouteAnnouncer />
|
||||
<div class="container">
|
||||
<NuxtPage />
|
||||
@@ -10,6 +11,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AnimatedBackground from './components/parts/AnimatedBackground.vue';
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
// Set global html lang attribute based on current locale
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
186
frontend/app/components/parts/AnimatedBackground.vue
Normal file
186
frontend/app/components/parts/AnimatedBackground.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let animId: number
|
||||
let particles: Particle[] = []
|
||||
let accentColor: string = ''
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
}
|
||||
|
||||
function parseAccent(hex: string): [number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return [r, g, b]
|
||||
}
|
||||
|
||||
function createParticles(w: number, h: number): Particle[] {
|
||||
const count = Math.floor((w * h) / 40000)
|
||||
const capped = Math.min(count, 80)
|
||||
const particlesArr: Particle[] = []
|
||||
|
||||
for (let i = 0; i < capped; i++) {
|
||||
particlesArr.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
size: Math.random() * 2 + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return particlesArr
|
||||
}
|
||||
|
||||
function drawConnections(w: number, h: number) {
|
||||
if (!ctx) return
|
||||
const [r, g, b] = parseAccent(accentColor)
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const a = particles[i]
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const b2 = particles[j]
|
||||
const dx = a.x - b2.x
|
||||
const dy = a.y - b2.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 100) {
|
||||
const connAlpha = (1 - dist / 100) * 0.25
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b2.x, b2.y)
|
||||
ctx.strokeStyle = `rgba(${r},${g},${b},${connAlpha})`
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTuiParticle(x: number, y: number, size: number, r: number, g: number, b: number) {
|
||||
if (!ctx) return
|
||||
const half = Math.ceil(size)
|
||||
|
||||
// Draw plus/cross shape (TUI cursor style)
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},0.85)`
|
||||
ctx.fillRect(Math.round(x - half), y - 1, size, 1)
|
||||
ctx.fillRect(x - 1, Math.round(y - half), 1, size)
|
||||
|
||||
// Center dot
|
||||
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2)
|
||||
}
|
||||
|
||||
function animate(): void {
|
||||
if (!ctx || !canvas.value) return
|
||||
const w = canvas.value.width
|
||||
const h = canvas.value.height
|
||||
const [r, g, b] = parseAccent(accentColor)
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
|
||||
for (const p of particles) {
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
if (p.x < -20) p.x = w + 20
|
||||
if (p.x > w + 20) p.x = -20
|
||||
if (p.y < -20) p.y = h + 20
|
||||
if (p.y > h + 20) p.y = -20
|
||||
|
||||
drawTuiParticle(p.x, p.y, p.size, r, g, b)
|
||||
}
|
||||
|
||||
drawConnections(w, h)
|
||||
animId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
accentColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-link')
|
||||
.trim()
|
||||
|
||||
if (!accentColor || !accentColor.startsWith('#')) return
|
||||
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
c.width = w * dpr
|
||||
c.height = h * dpr
|
||||
c.style.width = `${w}px`
|
||||
c.style.height = `${h}px`
|
||||
|
||||
if (ctx) ctx.scale(dpr, dpr)
|
||||
particles = createParticles(w, h)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
initCanvas()
|
||||
ctx = canvas.value.getContext('2d')
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
accentColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-link')
|
||||
.trim()
|
||||
if (canvas.value) {
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
canvas.value.style.width = `${w}px`
|
||||
canvas.value.style.height = `${h}px`
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { attributes: true })
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (!canvas.value || !ctx) return
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.value.width = w * dpr
|
||||
canvas.value.height = h * dpr
|
||||
canvas.value.style.width = `${w}px`
|
||||
canvas.value.style.height = `${h}px`
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
particles = createParticles(w, h)
|
||||
})
|
||||
|
||||
if (ctx && canvas.value) {
|
||||
animate()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="animated-bg" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.animated-bg {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
z-index: -1 !important;
|
||||
pointer-events: none !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
</style>
|
||||
2275
frontend/package-lock.json
generated
2275
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest run --reporter=verbose",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/content": "^3.12.0",
|
||||
@@ -18,5 +20,13 @@
|
||||
"sass": "^1.98.0",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/test-utils": "^3.15.4",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"happy-dom": "^20.10.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
213
frontend/test/e2e.test.ts
Normal file
213
frontend/test/e2e.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||
|
||||
// --- Mock queryCollection globally ---
|
||||
beforeAll(() => {
|
||||
const mockFixedContent = [
|
||||
{ path: '/fixed/en/root', locale: 'en', collection: 'fixed' },
|
||||
{ path: '/fixed/es/root', locale: 'es', collection: 'fixed' },
|
||||
{ path: '/fixed/ca/root', locale: 'ca', collection: 'fixed' },
|
||||
{ path: '/fixed/en/contact', locale: 'en', collection: 'fixed' },
|
||||
]
|
||||
|
||||
const mockBlogItems = [
|
||||
{ path: '/blog/en/test', slug: 'test', title: 'First Post', date: '2026-03-18', description: 'Testing testing' },
|
||||
{ path: '/blog/en/upgrade', slug: 'upgrade', title: 'Upgrade Post', date: '2026-04-01', description: 'System upgraded' },
|
||||
{ path: '/blog/en/translator', slug: 'translator', title: 'Translator Post', date: '2026-05-15', description: 'Auto translation' },
|
||||
]
|
||||
|
||||
const mockArtItems = [
|
||||
{ path: '/art/en/nozt', slug: 'nozt', title: 'Nozt Art', date: '2026-04-14', thumb: '/art/nozt.jpg' },
|
||||
{ path: '/art/en/silang-3d', slug: 'silang-3d', title: 'Silang 3D', date: '2026-05-20', thumb: '/art/silang.jpg' },
|
||||
]
|
||||
|
||||
const mockProjectItems = [
|
||||
{ path: '/projects/en/dragonroll', slug: 'dragonroll', title: 'Dragonroll', description: 'RPG helper', date: '2026-01-01', tech: ['Nuxt', 'Vue'] },
|
||||
]
|
||||
|
||||
global.queryCollection = function queryCollection(collectionName: string) {
|
||||
const items = {
|
||||
fixed: mockFixedContent,
|
||||
blog: mockBlogItems,
|
||||
art: mockArtItems,
|
||||
projects: mockProjectItems,
|
||||
}[collectionName] || []
|
||||
|
||||
return {
|
||||
path: (pathStr: string) => ({
|
||||
first: () => Promise.resolve(items.find((i: any) => i.path === pathStr) || null),
|
||||
all: () => Promise.resolve([items.find((i: any) => i.path === pathStr) || null].filter(Boolean)),
|
||||
}),
|
||||
where: (field: string, op: string, value: any) => {
|
||||
let filtered = [...items]
|
||||
if (op === 'LIKE') {
|
||||
const pattern = value.replace(/\*/g, '').replace(/%/g, '')
|
||||
filtered = items.filter((i: any) => i[field] && typeof i[field] === 'string' && i[field].startsWith(pattern))
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
all: () => Promise.resolve(filtered.sort((a: any, b: any) => {
|
||||
if (a.date && b.date) return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
return 0
|
||||
})),
|
||||
}),
|
||||
all: () => Promise.resolve(filtered),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
global.useState = vi.fn(() => ({ value: [] as any[] }))
|
||||
})
|
||||
|
||||
// --- Page components ---
|
||||
import { renderSuspended } from '@nuxt/test-utils/runtime'
|
||||
import IndexPage from '../app/pages/index.vue'
|
||||
import BlogListPage from '../app/pages/blog/index.vue'
|
||||
import ArtListPage from '../app/pages/art/index.vue'
|
||||
import ContactPage from '../app/pages/contact/index.vue'
|
||||
|
||||
function getHtml(wrapper: any) {
|
||||
return typeof wrapper.html === 'function' ? wrapper.html() : ''
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Home page tests
|
||||
// ======================
|
||||
|
||||
describe('Home page (index)', () => {
|
||||
it('renders table header with site navigation', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('ARANROIG')
|
||||
})
|
||||
|
||||
it('renders hidden H1 title for accessibility', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Aran Roig')
|
||||
})
|
||||
|
||||
it('renders fixed layout wrapper', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('fixed-layout')
|
||||
})
|
||||
|
||||
it('renders intro section', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('intro-section')
|
||||
})
|
||||
|
||||
it('renders projects section with project cards', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
// Verify Container component renders properly
|
||||
expect(html).toContain('Container')
|
||||
})
|
||||
|
||||
it('renders blog section', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Blog Entries')
|
||||
})
|
||||
|
||||
it('renders art gallery section', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Art Gallery')
|
||||
})
|
||||
|
||||
it('renders contact section', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Contact')
|
||||
})
|
||||
|
||||
it('renders footer component', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('tui-statusbar')
|
||||
})
|
||||
|
||||
it('has scroll target anchors', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('scroll-blog')
|
||||
expect(html).toContain('scroll-art')
|
||||
expect(html).toContain('scroll-contact')
|
||||
})
|
||||
|
||||
it('renders footer copyright text', async () => {
|
||||
const wrapper = await renderSuspended(IndexPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('2026 Aran Roig')
|
||||
})
|
||||
})
|
||||
|
||||
// ======================
|
||||
// Blog listing tests
|
||||
// ======================
|
||||
|
||||
describe('Blog listing page', () => {
|
||||
it('renders blog heading', async () => {
|
||||
const wrapper = await renderSuspended(BlogListPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Blog Entries')
|
||||
})
|
||||
|
||||
it('renders tui-list structure', async () => {
|
||||
const wrapper = await renderSuspended(BlogListPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('tui-list')
|
||||
})
|
||||
|
||||
it('renders layout', async () => {
|
||||
const wrapper = await renderSuspended(BlogListPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('fixed-layout')
|
||||
})
|
||||
})
|
||||
|
||||
// ======================
|
||||
// Art listing tests
|
||||
// ======================
|
||||
|
||||
describe('Art listing page', () => {
|
||||
it('renders art gallery heading', async () => {
|
||||
const wrapper = await renderSuspended(ArtListPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Art Gallery')
|
||||
})
|
||||
|
||||
it('renders grid layout', async () => {
|
||||
const wrapper = await renderSuspended(ArtListPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('grid')
|
||||
})
|
||||
|
||||
it('renders layout', async () => {
|
||||
const wrapper = await renderSuspended(ArtListPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('fixed-layout')
|
||||
})
|
||||
})
|
||||
|
||||
// ======================
|
||||
// Contact page tests
|
||||
// ======================
|
||||
|
||||
describe('Contact page', () => {
|
||||
it('renders contact heading', async () => {
|
||||
const wrapper = await renderSuspended(ContactPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('Contact')
|
||||
})
|
||||
|
||||
it('renders layout', async () => {
|
||||
const wrapper = await renderSuspended(ContactPage, { shallow: false })
|
||||
const html = getHtml(wrapper)
|
||||
expect(html).toContain('fixed-layout')
|
||||
expect(html).toContain('tui-statusbar')
|
||||
})
|
||||
})
|
||||
1
frontend/test/setup.ts
Normal file
1
frontend/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
// Test setup - global mocks for queryCollection defined per-test-file
|
||||
8
frontend/vitest.config.ts
Normal file
8
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineVitestConfig } from '@nuxt/test-utils/config'
|
||||
|
||||
export default defineVitestConfig({
|
||||
test: {
|
||||
environment: 'nuxt',
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
},
|
||||
})
|
||||
2581
package-lock.json
generated
2581
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
"@nuxt/test-utils": "^3.15.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n BACKEND,FRONTEND -c blue,green \"npm --prefix backend run dev\" \"npm --prefix frontend run dev\""
|
||||
|
||||
Reference in New Issue
Block a user