feat: render semantic portfolio document
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# Task 4 — Semantic Vue document
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented the universal Vue SSR document and typed semantic section components. The rendered document exposes the full eight-section portfolio reading order before hydration, with no visual-scene, audio, motion, SEO, or prerender work added.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Added `src/app.ts`, `src/entry-client.ts`, `src/entry-server.ts`, and `src/App.vue`.
|
||||||
|
- Added semantic shell, navigation, section, experience, infrastructure, project, toolkit, and contact components under `src/components/`.
|
||||||
|
- Added SSR/component coverage in `tests/unit/AppShell.test.ts` and `tests/unit/components.test.ts`.
|
||||||
|
- Repaired `vitest.config.ts` to load the already-installed Vue plugin; this was authorized as the necessary Task 1 integration repair.
|
||||||
|
|
||||||
|
## RED / GREEN evidence
|
||||||
|
|
||||||
|
- **RED:** `npm run test:unit -- tests/unit/AppShell.test.ts tests/unit/components.test.ts` failed before implementation. Vitest could not resolve the missing `@/entry-server` and `@/components/ContactDestination.vue` modules, so neither requested SSR nor component behavior existed.
|
||||||
|
- **Integration RED:** after adding the components, the same standard command failed because `vitest.config.ts` did not load `@vitejs/plugin-vue`, reporting that `.vue` source could not be parsed.
|
||||||
|
- **GREEN:** after the implementation and minimal Vue-plugin config repair, `npm run test:unit -- tests/unit/AppShell.test.ts tests/unit/components.test.ts` passed: 2 files, 8 tests.
|
||||||
|
- **Final verification:** `npm run test:unit` passed: 7 files, 35 tests; `npm run typecheck` passed; `git diff --check` passed.
|
||||||
|
|
||||||
|
## Component and accessibility decisions
|
||||||
|
|
||||||
|
- `createApp(inputs)` creates one SSR-capable app per input snapshot and provides the readonly inputs; the server renders a fresh app and the client hydrates only `window.__PORTFOLIO_INPUTS__`, without a content fetch.
|
||||||
|
- `AppShell` provides header, labelled native-link navigation, main, eight ordered sections, and a footer-scoped contact destination. It keeps exactly one H1 and uses H2/H3 hierarchy without skips.
|
||||||
|
- All displayed portfolio facts come from typed content props. Private project articles have no anchors; public URLs receive `target="_blank"` with `rel="noopener noreferrer"`.
|
||||||
|
- The infrastructure figure contains an inline named/described SVG, visible caption, and a visible text-list equivalent sourced from safe evidence bands. The SVG does not introduce topology identifiers.
|
||||||
|
- Toolkit placeholders are assetless and `aria-hidden`; each technology’s visible label supplies the only announced name.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- Verified no scene, audio, canvas, remote fetch, portrait, CV download, fake-metric, distro-label, or private-link code was introduced in the Task 4 sources.
|
||||||
|
- The supplied public content fixture remains the only subject-matter source; destination identity, visible email, CV message, and verified social links render in the footer.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
`feat: render semantic portfolio document`
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
None for this foundation scope. The future Start-the-Drive control and atmospheric destination treatment remain intentionally outside this semantic-only task.
|
||||||
10
src/App.vue
Normal file
10
src/App.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppShell from './components/AppShell.vue';
|
||||||
|
import type { ProductionInputs } from './content/types';
|
||||||
|
|
||||||
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell :content="inputs.content" />
|
||||||
|
</template>
|
||||||
11
src/app.ts
Normal file
11
src/app.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createSSRApp, type InjectionKey } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import type { ProductionInputs } from './content/types';
|
||||||
|
|
||||||
|
export const productionInputsKey: InjectionKey<Readonly<ProductionInputs>> = Symbol('production-inputs');
|
||||||
|
|
||||||
|
export function createApp(inputs: Readonly<ProductionInputs>) {
|
||||||
|
const app = createSSRApp(App, { inputs });
|
||||||
|
app.provide(productionInputsKey, inputs);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
64
src/components/AppShell.vue
Normal file
64
src/components/AppShell.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ContactDestination from './ContactDestination.vue';
|
||||||
|
import ExperienceTimeline from './ExperienceTimeline.vue';
|
||||||
|
import InfrastructureDiagram from './InfrastructureDiagram.vue';
|
||||||
|
import PortfolioSection from './PortfolioSection.vue';
|
||||||
|
import ProjectCard from './ProjectCard.vue';
|
||||||
|
import ProjectCaseStudy from './ProjectCaseStudy.vue';
|
||||||
|
import SiteNavigation from './SiteNavigation.vue';
|
||||||
|
import ToolkitGrid from './ToolkitGrid.vue';
|
||||||
|
import type { PortfolioContent } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{ content: Readonly<PortfolioContent> }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
||||||
|
<header>
|
||||||
|
<h1>{{ content.identity.name }}</h1>
|
||||||
|
<p>{{ content.identity.role }}</p>
|
||||||
|
<SiteNavigation :items="content.navigation" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="portfolio-content">
|
||||||
|
<PortfolioSection id="start" :index="1" :eyebrow="content.navigation[0]!.label.toUpperCase()" :heading="content.navigation[0]!.label.toUpperCase()">
|
||||||
|
<p>{{ content.identity.valueProposition }}</p>
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="profile" :index="2" :eyebrow="content.navigation[1]!.label.toUpperCase()" :heading="content.navigation[1]!.label.toUpperCase()">
|
||||||
|
<p>{{ content.profile.introduction }}</p>
|
||||||
|
<dl>
|
||||||
|
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
|
||||||
|
<dt>{{ fact.label }}</dt>
|
||||||
|
<dd>{{ fact.value }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="experience" :index="3" :eyebrow="content.navigation[2]!.label.toUpperCase()" :heading="content.navigation[2]!.label.toUpperCase()">
|
||||||
|
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="infrastructure" :index="4" :eyebrow="content.navigation[3]!.label.toUpperCase()" :heading="content.navigation[3]!.label.toUpperCase()">
|
||||||
|
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="selected-builds" :index="5" :eyebrow="content.navigation[4]!.label.toUpperCase()" :heading="content.navigation[4]!.label.toUpperCase()">
|
||||||
|
<ProjectCaseStudy v-for="project in content.projects.selected" :key="project.id" :project="project" />
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="additional-work" :index="6" :eyebrow="content.navigation[5]!.label.toUpperCase()" :heading="content.navigation[5]!.label.toUpperCase()">
|
||||||
|
<ProjectCard v-for="project in content.projects.additional" :key="project.id" :project="project" />
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="toolkit" :index="7" :eyebrow="content.navigation[6]!.label.toUpperCase()" :heading="content.navigation[6]!.label.toUpperCase()">
|
||||||
|
<ToolkitGrid :groups="content.toolkit" />
|
||||||
|
</PortfolioSection>
|
||||||
|
|
||||||
|
<PortfolioSection id="contact" :index="8" :eyebrow="content.navigation[7]!.label.toUpperCase()" :heading="content.navigation[7]!.label.toUpperCase()">
|
||||||
|
<footer>
|
||||||
|
<ContactDestination :contact="content.contact" :identity="content.identity" />
|
||||||
|
</footer>
|
||||||
|
</PortfolioSection>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
20
src/components/ContactDestination.vue
Normal file
20
src/components/ContactDestination.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContactContent, PortfolioContent } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
contact: Readonly<ContactContent>;
|
||||||
|
identity: Readonly<Pick<PortfolioContent['identity'], 'name' | 'role'>>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p>{{ identity.name }}</p>
|
||||||
|
<p>{{ identity.role }}</p>
|
||||||
|
<a :href="`mailto:${contact.email}`">{{ contact.email }}</a>
|
||||||
|
<p>{{ contact.cvMessage }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="link in contact.social" :key="link.service">
|
||||||
|
<a :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.service }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
30
src/components/ExperienceTimeline.vue
Normal file
30
src/components/ExperienceTimeline.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ExperienceRecord, SystemsCaseStudy } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
experience: readonly ExperienceRecord[];
|
||||||
|
systemsOperations: Readonly<SystemsCaseStudy>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ol>
|
||||||
|
<li v-for="record in experience" :key="record.id">
|
||||||
|
<article>
|
||||||
|
<h3>{{ record.role }}</h3>
|
||||||
|
<p>{{ record.organization }} · {{ record.dates }}<template v-if="record.location"> · {{ record.location }}</template></p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="highlight in record.highlights" :key="highlight">{{ highlight }}</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h3>{{ systemsOperations.title }}</h3>
|
||||||
|
<p>{{ systemsOperations.summary }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in systemsOperations.evidence" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
33
src/components/InfrastructureDiagram.vue
Normal file
33
src/components/InfrastructureDiagram.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { InfrastructureCaseStudy } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<h3>{{ infrastructure.title }}</h3>
|
||||||
|
<p>{{ infrastructure.summary }}</p>
|
||||||
|
<figure>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 800 240"
|
||||||
|
role="img"
|
||||||
|
:aria-labelledby="`${infrastructure.id}-diagram-title`"
|
||||||
|
:aria-describedby="`${infrastructure.id}-diagram-description`"
|
||||||
|
>
|
||||||
|
<title :id="`${infrastructure.id}-diagram-title`">{{ infrastructure.title }}</title>
|
||||||
|
<desc :id="`${infrastructure.id}-diagram-description`">{{ infrastructure.summary }}</desc>
|
||||||
|
<g v-for="(band, index) in infrastructure.bands" :key="band.id" :transform="`translate(${40 + index * 190} 70)`">
|
||||||
|
<rect width="150" height="100" rx="8" />
|
||||||
|
<text x="75" y="50" text-anchor="middle">{{ band.title }}</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<figcaption>{{ infrastructure.title }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
<ul data-testid="infrastructure-text-equivalent">
|
||||||
|
<li v-for="band in infrastructure.bands" :key="band.id">
|
||||||
|
<strong>{{ band.title }}</strong>: {{ band.summary }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
16
src/components/PortfolioSection.vue
Normal file
16
src/components/PortfolioSection.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
eyebrow: string;
|
||||||
|
heading: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :id="id" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
||||||
|
<p>{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
||||||
|
<h2 :id="`${id}-heading`">{{ heading }}</h2>
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
15
src/components/ProjectCard.vue
Normal file
15
src/components/ProjectCard.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PublicProject } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{ project: Readonly<PublicProject> }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<h3>{{ project.title }}</h3>
|
||||||
|
<p>{{ project.summary }}</p>
|
||||||
|
<p>{{ project.contribution }}</p>
|
||||||
|
<p>{{ project.outcome }}</p>
|
||||||
|
<a v-if="project.visibility === 'public' && project.url" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
27
src/components/ProjectCaseStudy.vue
Normal file
27
src/components/ProjectCaseStudy.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PublicProject } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{ project: Readonly<PublicProject> }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<h3>{{ project.title }}</h3>
|
||||||
|
<p>{{ project.summary }}</p>
|
||||||
|
<dl>
|
||||||
|
<dt>Problem</dt>
|
||||||
|
<dd>{{ project.problem }}</dd>
|
||||||
|
<dt>Contribution</dt>
|
||||||
|
<dd>{{ project.contribution }}</dd>
|
||||||
|
<dt>Technical decisions</dt>
|
||||||
|
<dd>
|
||||||
|
<ul>
|
||||||
|
<li v-for="decision in project.technicalDecisions" :key="decision">{{ decision }}</li>
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
<dt>Outcome</dt>
|
||||||
|
<dd>{{ project.outcome }}</dd>
|
||||||
|
</dl>
|
||||||
|
<a v-if="project.visibility === 'public' && project.url" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
15
src/components/SiteNavigation.vue
Normal file
15
src/components/SiteNavigation.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav aria-label="Portfolio sections">
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in items" :key="item.id">
|
||||||
|
<a :href="`#${item.id}`">{{ item.label }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
19
src/components/ToolkitGrid.vue
Normal file
19
src/components/ToolkitGrid.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ToolkitGroup } from '@/content/types';
|
||||||
|
|
||||||
|
defineProps<{ groups: readonly ToolkitGroup[] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section v-for="group in groups" :key="group.title" :aria-labelledby="`${group.title}-heading`">
|
||||||
|
<h3 :id="`${group.title}-heading`">{{ group.title }}</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="technology in group.technologies" :key="technology.name" data-testid="toolkit-technology">
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<span>{{ technology.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
src/entry-client.ts
Normal file
16
src/entry-client.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createApp } from './app';
|
||||||
|
import type { ProductionInputs } from './content/types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__PORTFOLIO_INPUTS__?: Readonly<ProductionInputs>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputs = window.__PORTFOLIO_INPUTS__;
|
||||||
|
|
||||||
|
if (inputs === undefined) {
|
||||||
|
throw new Error('The serialized portfolio input snapshot is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(inputs).mount('#app');
|
||||||
7
src/entry-server.ts
Normal file
7
src/entry-server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { renderToString } from '@vue/server-renderer';
|
||||||
|
import { createApp } from './app';
|
||||||
|
import type { ProductionInputs } from './content/types';
|
||||||
|
|
||||||
|
export async function render(_url: string, inputs: Readonly<ProductionInputs>) {
|
||||||
|
return renderToString(createApp(inputs));
|
||||||
|
}
|
||||||
63
tests/unit/AppShell.test.ts
Normal file
63
tests/unit/AppShell.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { validateProductionInputs } from '@/content/schemas';
|
||||||
|
import { render } from '@/entry-server';
|
||||||
|
|
||||||
|
const fixture = (name: string): unknown =>
|
||||||
|
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
||||||
|
|
||||||
|
const validInputs = validateProductionInputs(
|
||||||
|
fixture('portfolio-content.valid.json'),
|
||||||
|
fixture('asset-manifest.valid.json'),
|
||||||
|
fixture('site-config.preview.json'),
|
||||||
|
new URL('file:///prepared-assets/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedDocument = async (): Promise<Document> => {
|
||||||
|
const document = globalThis.document.implementation.createHTMLDocument('Portfolio');
|
||||||
|
document.body.innerHTML = await render('/', validInputs);
|
||||||
|
return document;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('semantic portfolio document', () => {
|
||||||
|
it('renders the complete landmark and heading reading structure on the server', async () => {
|
||||||
|
const document = await renderedDocument();
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('header')).toHaveLength(1);
|
||||||
|
expect(document.querySelector('nav[aria-label="Portfolio sections"]')).not.toBeNull();
|
||||||
|
expect(document.querySelectorAll('main')).toHaveLength(1);
|
||||||
|
expect(document.querySelectorAll('footer')).toHaveLength(1);
|
||||||
|
expect(document.querySelectorAll('h1')).toHaveLength(1);
|
||||||
|
expect(document.querySelector('h1')?.textContent).toBe('Luca Pîslaru');
|
||||||
|
expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([
|
||||||
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
]);
|
||||||
|
expect([...document.querySelectorAll('main > section')].every((section) => section.hasAttribute('aria-labelledby'))).toBe(true);
|
||||||
|
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
||||||
|
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT',
|
||||||
|
]);
|
||||||
|
expect(document.querySelector('h2 + h4')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps native section links and visible contact available without JavaScript', async () => {
|
||||||
|
const document = await renderedDocument();
|
||||||
|
const navigation = document.querySelector('nav[aria-label="Portfolio sections"]')!;
|
||||||
|
|
||||||
|
expect([...navigation.querySelectorAll('a')].map((link) => link.getAttribute('href'))).toEqual([
|
||||||
|
'#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact',
|
||||||
|
]);
|
||||||
|
const email = document.querySelector('a[href="mailto:hello@example.test"]');
|
||||||
|
expect(email?.textContent).toBe('hello@example.test');
|
||||||
|
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes systems and software evidence in server-rendered content', async () => {
|
||||||
|
const document = await renderedDocument();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Professional systems operations');
|
||||||
|
expect(document.body.textContent).toContain('Secure self-hosted platform and lifecycle');
|
||||||
|
expect(document.body.textContent).toContain('Private self-hosted medication tracker');
|
||||||
|
expect(document.body.textContent).toContain('Footy Tracker');
|
||||||
|
});
|
||||||
|
});
|
||||||
100
tests/unit/components.test.ts
Normal file
100
tests/unit/components.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import ContactDestination from '@/components/ContactDestination.vue';
|
||||||
|
import InfrastructureDiagram from '@/components/InfrastructureDiagram.vue';
|
||||||
|
import ProjectCaseStudy from '@/components/ProjectCaseStudy.vue';
|
||||||
|
import ToolkitGrid from '@/components/ToolkitGrid.vue';
|
||||||
|
import type { ContactContent, InfrastructureCaseStudy, PublicProject, ToolkitGroup } from '@/content/types';
|
||||||
|
|
||||||
|
const infrastructure: InfrastructureCaseStudy = {
|
||||||
|
id: 'safe-infrastructure',
|
||||||
|
title: 'Secure self-hosted platform and lifecycle',
|
||||||
|
summary: 'A compact infrastructure case study.',
|
||||||
|
bands: [
|
||||||
|
{ id: 'ingress-tls', title: 'Ingress and TLS', summary: 'Managed encrypted ingress.' },
|
||||||
|
{ id: 'identity-access', title: 'Identity and access', summary: 'Protected service access.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const privateProject: PublicProject = {
|
||||||
|
id: 'private-work',
|
||||||
|
title: 'Private self-hosted medication tracker',
|
||||||
|
visibility: 'private',
|
||||||
|
maturity: 'active',
|
||||||
|
summary: 'A private application.',
|
||||||
|
problem: 'A recurring workflow needs reliable tracking.',
|
||||||
|
contribution: 'Designed and maintained the application.',
|
||||||
|
technicalDecisions: ['Used a synchronised client and server design.'],
|
||||||
|
outcome: 'The work remains private.',
|
||||||
|
assetIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicProject: PublicProject = {
|
||||||
|
...privateProject,
|
||||||
|
id: 'public-work',
|
||||||
|
title: 'Footy Tracker',
|
||||||
|
visibility: 'public',
|
||||||
|
url: 'https://github.com/example/footy-tracker',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('semantic section components', () => {
|
||||||
|
it('gives the infrastructure graphic an accessible name, description, caption, and visible text equivalent', () => {
|
||||||
|
const wrapper = mount(InfrastructureDiagram, { props: { infrastructure } });
|
||||||
|
const svg = wrapper.get('svg');
|
||||||
|
|
||||||
|
expect(svg.attributes('role')).toBe('img');
|
||||||
|
expect(svg.attributes('aria-labelledby')).toContain('safe-infrastructure-diagram-title');
|
||||||
|
expect(svg.attributes('aria-describedby')).toContain('safe-infrastructure-diagram-description');
|
||||||
|
expect(wrapper.get('figcaption').text()).toContain('Secure self-hosted platform and lifecycle');
|
||||||
|
expect(wrapper.get('[data-testid="infrastructure-text-equivalent"]').text()).toContain('Ingress and TLS');
|
||||||
|
expect(wrapper.get('[data-testid="infrastructure-text-equivalent"]').text()).toContain('Identity and access');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not turn private project content into a link container', () => {
|
||||||
|
const wrapper = mount(ProjectCaseStudy, { props: { project: privateProject } });
|
||||||
|
|
||||||
|
expect(wrapper.get('article').findAll('a')).toHaveLength(0);
|
||||||
|
expect(wrapper.get('h3').text()).toBe('Private self-hosted medication tracker');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a safe external link only for a public project with an approved URL', () => {
|
||||||
|
const wrapper = mount(ProjectCaseStudy, { props: { project: publicProject } });
|
||||||
|
const link = wrapper.get('article a');
|
||||||
|
|
||||||
|
expect(link.attributes('href')).toBe('https://github.com/example/footy-tracker');
|
||||||
|
expect(link.attributes('target')).toBe('_blank');
|
||||||
|
expect(link.attributes('rel')).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces every toolkit technology once while hiding the decorative icon placeholder', () => {
|
||||||
|
const groups: readonly ToolkitGroup[] = [{
|
||||||
|
title: 'Systems & delivery',
|
||||||
|
technologies: [{ name: 'Linux' }],
|
||||||
|
}];
|
||||||
|
const wrapper = mount(ToolkitGrid, { props: { groups } });
|
||||||
|
const tile = wrapper.get('[data-testid="toolkit-technology"]');
|
||||||
|
|
||||||
|
expect(tile.text()).toBe('Linux');
|
||||||
|
expect(tile.get('[aria-hidden="true"]').attributes('aria-hidden')).toBe('true');
|
||||||
|
expect(tile.findAll('[aria-label="Linux"]').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a visible mailto contact and safe verified social links', () => {
|
||||||
|
const contact: ContactContent = {
|
||||||
|
email: 'hello@example.test',
|
||||||
|
cvMessage: 'For a current CV or role-specific information, contact me directly.',
|
||||||
|
social: [{ service: 'GitHub', url: 'https://github.com/example' }],
|
||||||
|
};
|
||||||
|
const wrapper = mount(ContactDestination, {
|
||||||
|
props: {
|
||||||
|
contact,
|
||||||
|
identity: { name: 'Luca Pîslaru', role: 'System Administrator & Full-Stack Developer' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe('hello@example.test');
|
||||||
|
const social = wrapper.get('a[href="https://github.com/example"]');
|
||||||
|
expect(social.text()).toBe('GitHub');
|
||||||
|
expect(social.attributes('rel')).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { fileURLToPath, URL } from 'node:url';
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user