diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5b2df6..868ccc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,12 @@ # Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages +name: CI Workflow on: - # Runs on pushes targeting the default branch + # Runs on pull requests targeting the main branch + pull_request: + branches: ["main"] + + # Runs on push to the main branch push: branches: ["main"] @@ -24,6 +28,7 @@ concurrency: jobs: # Single deploy job since we're just deploying deploy: + if: github.event_name == 'push' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -45,3 +50,17 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + # Job for running tests + test: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run build + - name: Run Tests + run: bun run test diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 454fad6..5ff3585 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest" }, "dependencies": { "i18next": "^23.16.4", @@ -19,6 +20,9 @@ "react-i18next": "^15.1.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/jsdom": "^21.1.7", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", @@ -28,8 +32,10 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "jsdom": "^26.0.0", "tailwindcss": "^3.4.13", "typescript": "^5.2.2", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vitest": "^3.0.0" } } diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..a098aee --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,11 @@ + +import { test, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import App from './App'; + +test('renders without crashing', async () => { + render(); + const linkElement = screen.getByTestId('title'); + expect(document.body).toContainElement(linkElement); +}); diff --git a/src/App.tsx b/src/App.tsx index 9a6fd37..b3f2a10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,43 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { X, PlusCircle, Share2, Redo2 } from 'lucide-react'; -import { QRCodeSVG } from 'qrcode.react'; -import ViewToggle from './components/ViewToggle'; -import { useTranslation } from 'react-i18next'; -import Tooth from './components/Tooth'; -import { FEATURES } from './config'; -import LanguageButtons from './components/LanguageButtons'; -import Logo from './components/Logo'; +import { useState, useEffect, useRef, useCallback } from "react"; +import { X, PlusCircle, Share2, Redo2 } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import ViewToggle from "./components/ViewToggle"; +import { useTranslation } from "react-i18next"; +import Tooth from "./components/Tooth"; +import { FEATURES } from "./config"; +import LanguageButtons from "./components/LanguageButtons"; +import Logo from "./components/Logo"; const teethLayout = [ [18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28], - [48, 47, 46, 45, 44, 43, 42, 41, 31, 32, 33, 34, 35, 36, 37, 38] + [48, 47, 46, 45, 44, 43, 42, 41, 31, 32, 33, 34, 35, 36, 37, 38], ]; const ELASTIC_TYPES = [ - { id: 0, name: 'Rabbit', color: '#FF5555', thickness: 3, icon: '🐰', time: '24h' }, - { id: 1, name: 'Chipmunk', color: '#5555CC', thickness: 4, icon: '🐿️', time: 'daytime' }, - { id: 2, name: 'Fox', color: '#44DD44', thickness: 5, icon: '🦊', time: 'nighttime' }, + { + id: 0, + name: "Rabbit", + color: "#FF5555", + thickness: 3, + icon: "🐰", + time: "24h", + }, + { + id: 1, + name: "Chipmunk", + color: "#5555CC", + thickness: 4, + icon: "🐿️", + time: "daytime", + }, + { + id: 2, + name: "Fox", + color: "#44DD44", + thickness: 5, + icon: "🦊", + time: "nighttime", + }, ]; type Elastic = { @@ -34,9 +55,11 @@ const ElasticPlacer = () => { const [isMirrorView, setIsMirrorView] = useState(false); const [elastics, setElastics] = useState([]); const [currentElastic, setCurrentElastic] = useState([]); - const [currentElasticType, setCurrentElasticType] = useState(ELASTIC_TYPES[0].id); - const [currentElasticTime, setCurrentElasticTime] = useState('24h'); - const [shareUrl, setShareUrl] = useState(''); + const [currentElasticType, setCurrentElasticType] = useState( + ELASTIC_TYPES[0].id + ); + const [currentElasticTime, setCurrentElasticTime] = useState("24h"); + const [shareUrl, setShareUrl] = useState(""); const [disabledTeeth, setDisabledTeeth] = useState([]); const [onHoverListItem, setOnHoverListItem] = useState(null); const toothRefs = useRef({}); @@ -46,9 +69,9 @@ const ElasticPlacer = () => { // Load from URL useEffect(() => { const params = new URLSearchParams(window.location.search); - const savedElastics = params.get('e'); - const savedDisabledTeeth = params.get('t'); - const savedMirrorView = params.get('m'); + const savedElastics = params.get("e"); + const savedDisabledTeeth = params.get("t"); + const savedMirrorView = params.get("m"); if (savedElastics) { try { @@ -56,8 +79,10 @@ const ElasticPlacer = () => { // Convert old format (if needed) to numeric IDs: parsedElastics = parsedElastics.map((el: any) => { // If el.type is a string name, convert to numeric id - if (typeof el.type === 'string') { - const foundType = ELASTIC_TYPES.find(et => et.name === el.type) || ELASTIC_TYPES[0]; + if (typeof el.type === "string") { + const foundType = + ELASTIC_TYPES.find((et) => et.name === el.type) || + ELASTIC_TYPES[0]; el.type = foundType.id; } return el; @@ -78,7 +103,7 @@ const ElasticPlacer = () => { } if (FEATURES.MIRROR_VIEW && savedMirrorView) { - setIsMirrorView(savedMirrorView === '1'); + setIsMirrorView(savedMirrorView === "1"); } initialLoadDone.current = true; @@ -89,16 +114,18 @@ const ElasticPlacer = () => { if (initialLoadDone.current) { const params = new URLSearchParams(); if (elastics.length > 0) { - params.set('e', JSON.stringify(elastics)); + params.set("e", JSON.stringify(elastics)); } if (FEATURES.DISABLE_TEETH && disabledTeeth.length > 0) { - params.set('t', JSON.stringify(disabledTeeth)); + params.set("t", JSON.stringify(disabledTeeth)); } if (FEATURES.MIRROR_VIEW) { - params.set('m', isMirrorView ? '1' : '0'); + params.set("m", isMirrorView ? "1" : "0"); } - const newUrl = `${window.location.origin}${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`; - window.history.pushState({ path: newUrl }, '', newUrl); + const newUrl = `${window.location.origin}${window.location.pathname}${ + params.toString() ? "?" + params.toString() : "" + }`; + window.history.pushState({ path: newUrl }, "", newUrl); setShareUrl(newUrl); } }, [elastics, disabledTeeth, isMirrorView]); @@ -110,46 +137,62 @@ const ElasticPlacer = () => { drawElastics(); }); } - }, [elastics, initialLoadDone, onHoverListItem,]); + }, [elastics, initialLoadDone, onHoverListItem]); - const toothAlreadyInElastic = useCallback((toothNumber: number) => { - return elastics.some(elastic => elastic.teeth.includes(toothNumber)); - }, [elastics]); + const toothAlreadyInElastic = useCallback( + (toothNumber: number) => { + return elastics.some((elastic) => elastic.teeth.includes(toothNumber)); + }, + [elastics] + ); - const handleToothClick = useCallback((number: number) => { - if (!FEATURES.ALLOW_MULTIPLE_ELASTICS_PER_TOOTH && toothAlreadyInElastic(number)) { - return; - } + const handleToothClick = useCallback( + (number: number) => { + if ( + !FEATURES.ALLOW_MULTIPLE_ELASTICS_PER_TOOTH && + toothAlreadyInElastic(number) + ) { + return; + } - if (!FEATURES.DISABLE_TEETH || !disabledTeeth.includes(number)) { - setCurrentElastic(prev => - prev.includes(number) - ? prev.filter(n => n !== number) - : [...prev, number] - ); - } - }, [disabledTeeth, toothAlreadyInElastic]); + if (!FEATURES.DISABLE_TEETH || !disabledTeeth.includes(number)) { + setCurrentElastic((prev) => + prev.includes(number) + ? prev.filter((n) => n !== number) + : [...prev, number] + ); + } + }, + [disabledTeeth, toothAlreadyInElastic] + ); const handleToothToggle = useCallback((number: number) => { if (FEATURES.DISABLE_TEETH) { - setDisabledTeeth(prev => + setDisabledTeeth((prev) => prev.includes(number) - ? prev.filter(n => n !== number) + ? prev.filter((n) => n !== number) : [...prev, number] ); - setCurrentElastic(prev => prev.filter(n => n !== number)); + setCurrentElastic((prev) => prev.filter((n) => n !== number)); } }, []); const addElastic = useCallback(() => { if (currentElastic.length > 1) { - setElastics(prev => [...prev, { teeth: currentElastic, type: currentElasticType, time: currentElasticTime }]); + setElastics((prev) => [ + ...prev, + { + teeth: currentElastic, + type: currentElasticType, + time: currentElasticTime, + }, + ]); setCurrentElastic([]); } }, [currentElastic, currentElasticType, currentElasticTime]); const removeElastic = useCallback((index: number) => { - setElastics(prev => prev.filter((_, i) => i !== index)); + setElastics((prev) => prev.filter((_, i) => i !== index)); }, []); const resetAll = useCallback(() => { @@ -157,7 +200,11 @@ const ElasticPlacer = () => { setCurrentElastic([]); setDisabledTeeth([]); setIsMirrorView(false); - window.history.pushState({ path: window.location.pathname }, '', window.location.pathname); + window.history.pushState( + { path: window.location.pathname }, + "", + window.location.pathname + ); setShareUrl(window.location.origin + window.location.pathname); }, []); @@ -179,67 +226,87 @@ const ElasticPlacer = () => { const shouldHighlight = onHoverListItem === index; - const elasticTypeConfig = ELASTIC_TYPES.find(et => et.id === elastic.type) || ELASTIC_TYPES[0]; + const elasticTypeConfig = + ELASTIC_TYPES.find((et) => et.id === elastic.type) || ELASTIC_TYPES[0]; const svgRect = svgRef.current.getBoundingClientRect(); - const points = elastic.teeth.map(toothNumber => { - const rect = toothRefs.current[toothNumber]?.getBoundingClientRect(); - if (!rect) return null; - - const baseX = rect.left - svgRect.left + rect.width / 2; - const x = isMirrorView - ? svgRect.width - baseX - : baseX; - return { - x, - y: rect.top - svgRect.top + rect.height / 2 - }; - }).filter(point => point !== null) as { x: number; y: number }[]; + const points = elastic.teeth + .map((toothNumber) => { + const rect = toothRefs.current[toothNumber]?.getBoundingClientRect(); + if (!rect) return null; + + const baseX = rect.left - svgRect.left + rect.width / 2; + const x = isMirrorView ? svgRect.width - baseX : baseX; + return { + x, + y: rect.top - svgRect.top + rect.height / 2, + }; + }) + .filter((point) => point !== null) as { x: number; y: number }[]; if (points.length > 1) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = points.map((point, i) => `${i === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); + const path = document.createElementNS( + "http://www.w3.org/2000/svg", + "path" + ); + const d = points + .map((point, i) => `${i === 0 ? "M" : "L"} ${point.x} ${point.y}`) + .join(" "); if (shouldHighlight) { - // draw a shadow behind the line - const shadow = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - shadow.setAttribute('d', d); - shadow.setAttribute('fill', 'none'); - shadow.setAttribute('stroke', '#000'); - shadow.setAttribute('stroke-width', (elasticTypeConfig.thickness + 4).toString()); - shadow.setAttribute('stroke-opacity', '0.5'); + const shadow = document.createElementNS( + "http://www.w3.org/2000/svg", + "path" + ); + shadow.setAttribute("d", d); + shadow.setAttribute("fill", "none"); + shadow.setAttribute("stroke", "#000"); + shadow.setAttribute( + "stroke-width", + (elasticTypeConfig.thickness + 4).toString() + ); + shadow.setAttribute("stroke-opacity", "0.5"); // round line ends & joins - shadow.setAttribute('stroke-linecap', 'round'); - shadow.setAttribute('stroke-linejoin', 'round'); + shadow.setAttribute("stroke-linecap", "round"); + shadow.setAttribute("stroke-linejoin", "round"); svgRef.current.appendChild(shadow); // draw an opaque circle behind every point points.forEach((point) => { - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', point.x.toString()); - circle.setAttribute('cy', point.y.toString()); - circle.setAttribute('r', '10'); - circle.setAttribute('fill', elasticTypeConfig.color); - circle.setAttribute('stroke', '#000'); - circle.setAttribute('stroke-width', '2'); - circle.setAttribute('stroke-opacity', '0.5'); + const circle = document.createElementNS( + "http://www.w3.org/2000/svg", + "circle" + ); + circle.setAttribute("cx", point.x.toString()); + circle.setAttribute("cy", point.y.toString()); + circle.setAttribute("r", "10"); + circle.setAttribute("fill", elasticTypeConfig.color); + circle.setAttribute("stroke", "#000"); + circle.setAttribute("stroke-width", "2"); + circle.setAttribute("stroke-opacity", "0.5"); svgRef.current?.appendChild(circle); }); } - path.setAttribute('d', d); - path.setAttribute('fill', 'none'); - path.setAttribute('stroke', elasticTypeConfig.color); - path.setAttribute('stroke-width', elasticTypeConfig.thickness.toString()); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", elasticTypeConfig.color); + path.setAttribute( + "stroke-width", + elasticTypeConfig.thickness.toString() + ); // round line ends & joins - path.setAttribute('stroke-linecap', 'round'); - path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); // Add a title for hover tooltip - const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); - title.textContent = `${t('elastics.elasticTooltip', { + const title = document.createElementNS( + "http://www.w3.org/2000/svg", + "title" + ); + title.textContent = `${t("elastics.elasticTooltip", { type: elastic.type, - teeth: elastic.teeth.join(' → ') + teeth: elastic.teeth.join(" → "), })}`; path.appendChild(title); @@ -253,41 +320,49 @@ const ElasticPlacer = () => { drawElastics(); }; - window.addEventListener('resize', handleResize); + window.addEventListener("resize", handleResize); return () => { - window.removeEventListener('resize', handleResize); + window.removeEventListener("resize", handleResize); }; }, [drawElastics]); const handleShare = useCallback(() => { if (navigator.share) { - navigator.share({ - title: t('share.title'), - url: shareUrl - }).catch((error) => console.log('Error sharing', error)); + navigator + .share({ + title: t("share.title"), + url: shareUrl, + }) + .catch((error) => console.log("Error sharing", error)); } else { - navigator.clipboard.writeText(shareUrl).then(() => { - alert(t('share.copied')); - }, (err) => { - console.error(t('share.copyError'), err); - }); + navigator.clipboard.writeText(shareUrl).then( + () => { + alert(t("share.copied")); + }, + (err) => { + console.error(t("share.copyError"), err); + } + ); } }, [shareUrl, t]); return (
-

+

- {t('title')} + {t("title")}

{/* View Toggle */} {FEATURES.MIRROR_VIEW && ( setIsMirrorView(prev => !prev)} + onToggle={() => setIsMirrorView((prev) => !prev)} /> )} @@ -298,18 +373,18 @@ const ElasticPlacer = () => { <>
- {t('legend.middleIncisors')} + {t("legend.middleIncisors")}
- {t('legend.canines')} + {t("legend.canines")}
)} {FEATURES.DISABLE_TEETH && (
- {t('legend.disabledTeeth')} + {t("legend.disabledTeeth")}
)}
@@ -319,7 +394,10 @@ const ElasticPlacer = () => {
{ELASTIC_TYPES.map((etype) => ( -
+
{ /> {etype.icon} - {t('legend.elasticType', { type: etype.name })} + + {t("legend.elasticType", { type: etype.name })} +
))}
@@ -339,9 +419,15 @@ const ElasticPlacer = () => {
- + {teethLayout.map((row, rowIndex) => (
{row.map((tooth) => ( @@ -352,7 +438,9 @@ const ElasticPlacer = () => { onClick={handleToothClick} onToggle={handleToothToggle} selected={currentElastic.includes(tooth)} - disabled={FEATURES.DISABLE_TEETH && disabledTeeth.includes(tooth)} + disabled={ + FEATURES.DISABLE_TEETH && disabledTeeth.includes(tooth) + } setRef={setToothRef} isMirrorView={isMirrorView} /> @@ -371,13 +459,27 @@ const ElasticPlacer = () => { ))}
@@ -385,11 +487,15 @@ const ElasticPlacer = () => { {/* Time-based selection */} {FEATURES.TIME_BASED_ELASTICS && (
- {['24h', 'daytime', 'nighttime'].map((time) => ( + {["24h", "daytime", "nighttime"].map((time) => ( @@ -399,12 +505,18 @@ const ElasticPlacer = () => {
-
+
-

{t('elastics.title')}

+

{t("elastics.title")}

{elastics.length === 0 && ( -

{t('elastics.none')}

+

{t("elastics.none")}

)} {elastics.map((elastic, index) => { - const etype = ELASTIC_TYPES.find(et => et.id === elastic.type) || ELASTIC_TYPES[0]; + const etype = + ELASTIC_TYPES.find((et) => et.id === elastic.type) || + ELASTIC_TYPES[0]; return (
setOnHoverListItem(index)} onMouseLeave={() => setOnHoverListItem(null)} > - {t('elastics.elastic', { number: index + 1, teeth: elastic.teeth.join(' → ') })} -  - {t('elastics.elasticTypeDisplay', { type: etype.name })}  - - + {t("elastics.elastic", { + number: index + 1, + teeth: elastic.teeth.join(" → "), + })} +  -{" "} + {t("elastics.elasticTypeDisplay", { type: etype.name })}  + + { {FEATURES.TIME_BASED_ELASTICS && ( - - { elastic.time === '24h' ? '🏪' : elastic.time === 'daytime' ? '☀️' : '😴' } + + {elastic.time === "24h" + ? "🏪" + : elastic.time === "daytime" + ? "☀️" + : "😴"} )}
- {t('footer.copyright', { year: new Date().getFullYear() })} + {t("footer.copyright", { year: new Date().getFullYear() })}   - Koko Koding + + Koko Koding + diff --git a/vite.config.ts b/vite.config.ts index 51df4a1..7c73545 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,4 +5,7 @@ import react from '@vitejs/plugin-react-swc' export default defineConfig({ plugins: [react()], base: '/elastistent/', + test: { + environment: 'jsdom' + } }) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5270893 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + }, +});