From 976cee57110170e85f6727071360dc34582fe858 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 17 Dec 2025 07:35:10 +0700 Subject: [PATCH 1/3] #13 feat: add Playwright e2e test infrastructure --- e2e/.gitignore | 4 + e2e/package-lock.json | 111 ++++++++++++++++++++++++++ e2e/package.json | 21 +++++ e2e/playwright.config.ts | 55 +++++++++++++ e2e/tests/basic.spec.ts | 90 +++++++++++++++++++++ e2e/tests/bootstrap.spec.ts | 109 +++++++++++++++++++++++++ e2e/tests/fixtures.ts | 90 +++++++++++++++++++++ e2e/tests/nested-routes.spec.ts | 137 ++++++++++++++++++++++++++++++++ e2e/tests/tailwind.spec.ts | 119 +++++++++++++++++++++++++++ e2e/tsconfig.json | 16 ++++ 10 files changed, 752 insertions(+) create mode 100644 e2e/.gitignore create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/basic.spec.ts create mode 100644 e2e/tests/bootstrap.spec.ts create mode 100644 e2e/tests/fixtures.ts create mode 100644 e2e/tests/nested-routes.spec.ts create mode 100644 e2e/tests/tailwind.spec.ts create mode 100644 e2e/tsconfig.json diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..968c768 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +.playwright/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..99c9271 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "yew-nav-link-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yew-nav-link-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..92dacdc --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,21 @@ +{ + "name": "yew-nav-link-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:basic": "playwright test tests/basic.spec.ts", + "test:nested": "playwright test tests/nested-routes.spec.ts", + "test:bootstrap": "playwright test tests/bootstrap.spec.ts", + "test:tailwind": "playwright test tests/tailwind.spec.ts", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..a190b24 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,55 @@ +import { defineConfig, devices } from "@playwright/test"; + +const EXAMPLES = { + basic: 8080, + "nested-routes": 8081, + bootstrap: 8082, + tailwind: 8083, +} as const; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["html", { open: "never" }], ["list"]], + + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "mobile-safari", + use: { ...devices["iPhone 12"] }, + }, + ], + + webServer: Object.entries(EXAMPLES).map(([name, port]) => ({ + command: `cd ../examples/${name} && trunk serve --port ${port}`, + url: `http://localhost:${port}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + })), +}); + +export { EXAMPLES }; diff --git a/e2e/tests/basic.spec.ts b/e2e/tests/basic.spec.ts new file mode 100644 index 0000000..1518aab --- /dev/null +++ b/e2e/tests/basic.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "./fixtures"; + +test.describe("Basic Example", () => { + test.beforeEach(async ({ basic }) => { + await basic.goto(); + }); + + test("renders navigation with all links", async ({ basic }) => { + await expect(basic.navLink("Home")).toBeVisible(); + await expect(basic.navLink("About")).toBeVisible(); + await expect(basic.navLink("Contact")).toBeVisible(); + }); + + test("Home link is active on initial load", async ({ basic }) => { + await expect(basic.navLink("Home")).toHaveClass(/active/); + await expect(basic.navLink("About")).not.toHaveClass(/active/); + await expect(basic.navLink("Contact")).not.toHaveClass(/active/); + }); + + test("displays Home page content", async ({ basic }) => { + await expect(basic.heading()).toHaveText("Home"); + }); + + test("navigates to About and updates active state", async ({ basic }) => { + await basic.clickNav("About"); + + await expect(basic.navLink("About")).toHaveClass(/active/); + await expect(basic.navLink("Home")).not.toHaveClass(/active/); + await expect(basic.heading()).toHaveText("About"); + }); + + test("navigates to Contact and updates active state", async ({ basic }) => { + await basic.clickNav("Contact"); + + await expect(basic.navLink("Contact")).toHaveClass(/active/); + await expect(basic.navLink("Home")).not.toHaveClass(/active/); + await expect(basic.heading()).toHaveText("Contact"); + }); + + test("navigation sequence maintains correct active states", async ({ + basic, + }) => { + await basic.clickNav("About"); + await expect(basic.navLink("About")).toHaveClass(/active/); + + await basic.clickNav("Contact"); + await expect(basic.navLink("Contact")).toHaveClass(/active/); + await expect(basic.navLink("About")).not.toHaveClass(/active/); + + await basic.clickNav("Home"); + await expect(basic.navLink("Home")).toHaveClass(/active/); + await expect(basic.navLink("Contact")).not.toHaveClass(/active/); + }); + + test("direct URL navigation sets correct active state", async ({ basic }) => { + await basic.goto("/about"); + + await expect(basic.navLink("About")).toHaveClass(/active/); + await expect(basic.navLink("Home")).not.toHaveClass(/active/); + await expect(basic.heading()).toHaveText("About"); + }); + + test("handles 404 page", async ({ basic }) => { + await basic.goto("/nonexistent"); + + await expect(basic.heading()).toHaveText("404 - Not Found"); + }); + + test("all NavLinks have nav-link class", async ({ basic }) => { + const links = await basic.page.locator("nav a").all(); + + for (const link of links) { + await expect(link).toHaveClass(/nav-link/); + } + }); + + test("browser back/forward navigation works", async ({ basic }) => { + await basic.clickNav("About"); + await expect(basic.navLink("About")).toHaveClass(/active/); + + await basic.clickNav("Contact"); + await expect(basic.navLink("Contact")).toHaveClass(/active/); + + await basic.page.goBack(); + await expect(basic.navLink("About")).toHaveClass(/active/); + + await basic.page.goForward(); + await expect(basic.navLink("Contact")).toHaveClass(/active/); + }); +}); diff --git a/e2e/tests/bootstrap.spec.ts b/e2e/tests/bootstrap.spec.ts new file mode 100644 index 0000000..ebc8b9f --- /dev/null +++ b/e2e/tests/bootstrap.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "./fixtures"; + +test.describe("Bootstrap Example", () => { + test.beforeEach(async ({ bootstrap }) => { + await bootstrap.goto(); + }); + + test("renders Bootstrap navbar", async ({ bootstrap }) => { + await expect(bootstrap.page.locator(".navbar")).toBeVisible(); + await expect(bootstrap.page.locator(".navbar-brand")).toHaveText("MyApp"); + }); + + test("renders all navigation links", async ({ bootstrap }) => { + await expect(bootstrap.navLink("Home")).toBeVisible(); + await expect(bootstrap.navLink("Products")).toBeVisible(); + await expect(bootstrap.navLink("Pricing")).toBeVisible(); + await expect(bootstrap.navLink("Contact")).toBeVisible(); + }); + + test("Home is active on initial load", async ({ bootstrap }) => { + await expect(bootstrap.navLink("Home")).toHaveClass(/active/); + await expect(bootstrap.page.locator(".card-title")).toHaveText("Welcome"); + }); + + test("NavLinks have Bootstrap-compatible classes", async ({ bootstrap }) => { + const links = await bootstrap.page.locator(".navbar-nav a").all(); + + for (const link of links) { + await expect(link).toHaveClass(/nav-link/); + } + }); + + test("navigates to Products", async ({ bootstrap }) => { + await bootstrap.clickNav("Products"); + + await expect(bootstrap.navLink("Products")).toHaveClass(/active/); + await expect(bootstrap.navLink("Home")).not.toHaveClass(/active/); + await expect(bootstrap.page.locator(".card-title")).toHaveText("Products"); + }); + + test("navigates to Pricing", async ({ bootstrap }) => { + await bootstrap.clickNav("Pricing"); + + await expect(bootstrap.navLink("Pricing")).toHaveClass(/active/); + await expect(bootstrap.page.locator(".card-title")).toHaveText("Pricing"); + }); + + test("navigates to Contact", async ({ bootstrap }) => { + await bootstrap.clickNav("Contact"); + + await expect(bootstrap.navLink("Contact")).toHaveClass(/active/); + await expect(bootstrap.page.locator(".card-title")).toHaveText("Contact"); + }); + + test("full navigation cycle", async ({ bootstrap }) => { + const routes = ["Products", "Pricing", "Contact", "Home"]; + + for (const route of routes) { + await bootstrap.clickNav(route); + await expect(bootstrap.navLink(route)).toHaveClass(/active/); + } + }); + + test("direct URL navigation", async ({ bootstrap }) => { + await bootstrap.goto("/pricing"); + + await expect(bootstrap.navLink("Pricing")).toHaveClass(/active/); + await expect(bootstrap.page.locator(".card-title")).toHaveText("Pricing"); + }); + + test("handles 404 with Bootstrap alert", async ({ bootstrap }) => { + await bootstrap.goto("/nonexistent"); + + await expect(bootstrap.page.locator(".alert-warning")).toBeVisible(); + await expect(bootstrap.page.locator(".alert-heading")).toHaveText( + "404 - Not Found" + ); + }); + + test("browser history navigation", async ({ bootstrap }) => { + await bootstrap.clickNav("Products"); + await bootstrap.clickNav("Pricing"); + + await bootstrap.page.goBack(); + await expect(bootstrap.navLink("Products")).toHaveClass(/active/); + + await bootstrap.page.goBack(); + await expect(bootstrap.navLink("Home")).toHaveClass(/active/); + + await bootstrap.page.goForward(); + await expect(bootstrap.navLink("Products")).toHaveClass(/active/); + }); + + test("only one link is active at a time", async ({ bootstrap }) => { + const routes = ["Home", "Products", "Pricing", "Contact"]; + + for (const active of routes) { + await bootstrap.clickNav(active); + + for (const route of routes) { + if (route === active) { + await expect(bootstrap.navLink(route)).toHaveClass(/active/); + } else { + await expect(bootstrap.navLink(route)).not.toHaveClass(/active/); + } + } + } + }); +}); diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts new file mode 100644 index 0000000..1f928cd --- /dev/null +++ b/e2e/tests/fixtures.ts @@ -0,0 +1,90 @@ +import { test as base, type Page, type Locator } from "@playwright/test"; + +const PORTS = { + basic: 8080, + "nested-routes": 8081, + bootstrap: 8082, + tailwind: 8083, +} as const; + +type ExampleName = keyof typeof PORTS; + +export class NavLinkPage { + readonly page: Page; + readonly baseUrl: string; + + constructor(page: Page, example: ExampleName) { + this.page = page; + this.baseUrl = `http://localhost:${PORTS[example]}`; + } + + async goto(path = "/"): Promise { + await this.page.goto(`${this.baseUrl}${path}`); + await this.waitForWasm(); + } + + async waitForWasm(): Promise { + await this.page.waitForFunction(() => { + return document.querySelector("nav") !== null; + }); + } + + navLink(text: string): Locator { + return this.page.locator(`nav a:has-text("${text}")`); + } + + mainNavLink(text: string): Locator { + return this.page.locator(`.main-nav a:has-text("${text}")`); + } + + subNavLink(text: string): Locator { + return this.page.locator(`.sub-nav a:has-text("${text}")`); + } + + heading(level: 1 | 2 | 3 = 1): Locator { + return this.page.locator(`h${level}`).first(); + } + + async clickNav(text: string): Promise { + await this.navLink(text).click(); + await this.page.waitForLoadState("networkidle"); + } + + async expectActive(text: string): Promise { + await this.navLink(text).waitFor({ state: "visible" }); + const link = this.navLink(text); + await link.evaluate((el) => el.classList.contains("active")); + } + + async getActiveLinks(): Promise { + const links = await this.page.locator("nav a.active").all(); + const texts: string[] = []; + for (const link of links) { + const text = await link.textContent(); + if (text) texts.push(text.trim()); + } + return texts; + } +} + +export const test = base.extend<{ + basic: NavLinkPage; + nestedRoutes: NavLinkPage; + bootstrap: NavLinkPage; + tailwind: NavLinkPage; +}>({ + basic: async ({ page }, use) => { + await use(new NavLinkPage(page, "basic")); + }, + nestedRoutes: async ({ page }, use) => { + await use(new NavLinkPage(page, "nested-routes")); + }, + bootstrap: async ({ page }, use) => { + await use(new NavLinkPage(page, "bootstrap")); + }, + tailwind: async ({ page }, use) => { + await use(new NavLinkPage(page, "tailwind")); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/tests/nested-routes.spec.ts b/e2e/tests/nested-routes.spec.ts new file mode 100644 index 0000000..bc2eb02 --- /dev/null +++ b/e2e/tests/nested-routes.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from "./fixtures"; + +test.describe("Nested Routes Example", () => { + test.beforeEach(async ({ nestedRoutes }) => { + await nestedRoutes.goto(); + }); + + test("renders main navigation", async ({ nestedRoutes }) => { + await expect(nestedRoutes.mainNavLink("Home")).toBeVisible(); + await expect(nestedRoutes.mainNavLink("Documentation")).toBeVisible(); + await expect(nestedRoutes.mainNavLink("Blog")).toBeVisible(); + }); + + test("Home is active on initial load", async ({ nestedRoutes }) => { + await expect(nestedRoutes.mainNavLink("Home")).toHaveClass(/active/); + await expect(nestedRoutes.heading()).toHaveText("Nested Routes Example"); + }); + + test.describe("Documentation Section", () => { + test.beforeEach(async ({ nestedRoutes }) => { + await nestedRoutes.mainNavLink("Documentation").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + }); + + test("shows sub-navigation", async ({ nestedRoutes }) => { + await expect(nestedRoutes.subNavLink("Overview")).toBeVisible(); + await expect(nestedRoutes.subNavLink("Getting Started")).toBeVisible(); + await expect(nestedRoutes.subNavLink("API Reference")).toBeVisible(); + }); + + test("main nav Documentation stays active", async ({ nestedRoutes }) => { + await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass( + /active/ + ); + await expect(nestedRoutes.mainNavLink("Home")).not.toHaveClass(/active/); + }); + + test("Overview is active by default", async ({ nestedRoutes }) => { + await expect(nestedRoutes.subNavLink("Overview")).toHaveClass(/active/); + await expect(nestedRoutes.heading()).toHaveText("Documentation Overview"); + }); + + test("navigates to Getting Started", async ({ nestedRoutes }) => { + await nestedRoutes.subNavLink("Getting Started").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + + await expect(nestedRoutes.subNavLink("Getting Started")).toHaveClass( + /active/ + ); + await expect(nestedRoutes.subNavLink("Overview")).not.toHaveClass( + /active/ + ); + await expect(nestedRoutes.heading()).toHaveText("Getting Started"); + }); + + test("navigates to API Reference", async ({ nestedRoutes }) => { + await nestedRoutes.subNavLink("API Reference").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + + await expect(nestedRoutes.subNavLink("API Reference")).toHaveClass( + /active/ + ); + await expect(nestedRoutes.heading()).toHaveText("API Reference"); + }); + }); + + test.describe("Blog Section", () => { + test.beforeEach(async ({ nestedRoutes }) => { + await nestedRoutes.mainNavLink("Blog").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + }); + + test("shows sub-navigation", async ({ nestedRoutes }) => { + await expect(nestedRoutes.subNavLink("Latest Posts")).toBeVisible(); + await expect(nestedRoutes.subNavLink("Archive")).toBeVisible(); + await expect(nestedRoutes.subNavLink("Categories")).toBeVisible(); + }); + + test("main nav Blog stays active", async ({ nestedRoutes }) => { + await expect(nestedRoutes.mainNavLink("Blog")).toHaveClass(/active/); + await expect(nestedRoutes.mainNavLink("Documentation")).not.toHaveClass( + /active/ + ); + }); + + test("Latest Posts is active by default", async ({ nestedRoutes }) => { + await expect(nestedRoutes.subNavLink("Latest Posts")).toHaveClass( + /active/ + ); + await expect(nestedRoutes.heading()).toHaveText("Latest Posts"); + }); + + test("navigates through all blog pages", async ({ nestedRoutes }) => { + await nestedRoutes.subNavLink("Archive").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + await expect(nestedRoutes.subNavLink("Archive")).toHaveClass(/active/); + await expect(nestedRoutes.heading()).toHaveText("Archive"); + + await nestedRoutes.subNavLink("Categories").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + await expect(nestedRoutes.subNavLink("Categories")).toHaveClass(/active/); + await expect(nestedRoutes.heading()).toHaveText("Categories"); + }); + }); + + test("switching between sections updates both navs", async ({ + nestedRoutes, + }) => { + await nestedRoutes.mainNavLink("Documentation").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass( + /active/ + ); + await expect(nestedRoutes.subNavLink("Overview")).toHaveClass(/active/); + + await nestedRoutes.mainNavLink("Blog").click(); + await nestedRoutes.page.waitForLoadState("networkidle"); + await expect(nestedRoutes.mainNavLink("Blog")).toHaveClass(/active/); + await expect(nestedRoutes.mainNavLink("Documentation")).not.toHaveClass( + /active/ + ); + await expect(nestedRoutes.subNavLink("Latest Posts")).toHaveClass(/active/); + }); + + test("direct URL to nested route works", async ({ nestedRoutes }) => { + await nestedRoutes.goto("/docs/api"); + + await expect(nestedRoutes.subNavLink("API Reference")).toHaveClass(/active/); + await expect(nestedRoutes.heading()).toHaveText("API Reference"); + }); + + test("handles 404 page", async ({ nestedRoutes }) => { + await nestedRoutes.goto("/nonexistent"); + + await expect(nestedRoutes.heading()).toHaveText("404 - Not Found"); + }); +}); diff --git a/e2e/tests/tailwind.spec.ts b/e2e/tests/tailwind.spec.ts new file mode 100644 index 0000000..19284b2 --- /dev/null +++ b/e2e/tests/tailwind.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "./fixtures"; + +test.describe("Tailwind Example", () => { + test.beforeEach(async ({ tailwind }) => { + await tailwind.goto(); + }); + + test("renders sidebar navigation", async ({ tailwind }) => { + await expect(tailwind.page.locator("aside")).toBeVisible(); + await expect( + tailwind.page.locator('aside .text-xl:has-text("Dashboard")') + ).toBeVisible(); + }); + + test("renders all navigation links", async ({ tailwind }) => { + await expect(tailwind.navLink("Dashboard")).toBeVisible(); + await expect(tailwind.navLink("Analytics")).toBeVisible(); + await expect(tailwind.navLink("Settings")).toBeVisible(); + }); + + test("Dashboard is active on initial load", async ({ tailwind }) => { + await expect(tailwind.navLink("Dashboard")).toHaveClass(/active/); + await expect(tailwind.heading()).toHaveText("Dashboard"); + }); + + test("displays dashboard stats cards", async ({ tailwind }) => { + await expect(tailwind.page.locator("text=Users")).toBeVisible(); + await expect(tailwind.page.locator("text=1,234")).toBeVisible(); + await expect(tailwind.page.locator("text=Revenue")).toBeVisible(); + await expect(tailwind.page.locator("text=$12,345")).toBeVisible(); + await expect(tailwind.page.locator("text=Orders")).toBeVisible(); + await expect(tailwind.page.locator("text=567")).toBeVisible(); + }); + + test("NavLinks have nav-link class", async ({ tailwind }) => { + const links = await tailwind.page.locator("nav a").all(); + + for (const link of links) { + await expect(link).toHaveClass(/nav-link/); + } + }); + + test("navigates to Analytics", async ({ tailwind }) => { + await tailwind.clickNav("Analytics"); + + await expect(tailwind.navLink("Analytics")).toHaveClass(/active/); + await expect(tailwind.navLink("Dashboard")).not.toHaveClass(/active/); + await expect(tailwind.heading()).toHaveText("Analytics"); + }); + + test("navigates to Settings", async ({ tailwind }) => { + await tailwind.clickNav("Settings"); + + await expect(tailwind.navLink("Settings")).toHaveClass(/active/); + await expect(tailwind.navLink("Dashboard")).not.toHaveClass(/active/); + await expect(tailwind.heading()).toHaveText("Settings"); + }); + + test("full navigation cycle", async ({ tailwind }) => { + await tailwind.clickNav("Analytics"); + await expect(tailwind.navLink("Analytics")).toHaveClass(/active/); + + await tailwind.clickNav("Settings"); + await expect(tailwind.navLink("Settings")).toHaveClass(/active/); + + await tailwind.clickNav("Dashboard"); + await expect(tailwind.navLink("Dashboard")).toHaveClass(/active/); + }); + + test("direct URL navigation", async ({ tailwind }) => { + await tailwind.goto("/analytics"); + + await expect(tailwind.navLink("Analytics")).toHaveClass(/active/); + await expect(tailwind.heading()).toHaveText("Analytics"); + }); + + test("handles 404 page", async ({ tailwind }) => { + await tailwind.goto("/nonexistent"); + + await expect(tailwind.page.locator("h1:has-text('404')")).toBeVisible(); + await expect(tailwind.page.locator("text=Page not found")).toBeVisible(); + }); + + test("browser history navigation", async ({ tailwind }) => { + await tailwind.clickNav("Analytics"); + await tailwind.clickNav("Settings"); + + await tailwind.page.goBack(); + await expect(tailwind.navLink("Analytics")).toHaveClass(/active/); + + await tailwind.page.goBack(); + await expect(tailwind.navLink("Dashboard")).toHaveClass(/active/); + + await tailwind.page.goForward(); + await expect(tailwind.navLink("Analytics")).toHaveClass(/active/); + }); + + test("only one link is active at a time", async ({ tailwind }) => { + const routes = ["Dashboard", "Analytics", "Settings"]; + + for (const active of routes) { + await tailwind.clickNav(active); + + for (const route of routes) { + if (route === active) { + await expect(tailwind.navLink(route)).toHaveClass(/active/); + } else { + await expect(tailwind.navLink(route)).not.toHaveClass(/active/); + } + } + } + }); + + test("layout has correct structure", async ({ tailwind }) => { + await expect(tailwind.page.locator(".min-h-screen.flex")).toBeVisible(); + await expect(tailwind.page.locator("aside.w-64")).toBeVisible(); + await expect(tailwind.page.locator("main.flex-1")).toBeVisible(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..85836a9 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": true, + "types": ["node"] + }, + "include": ["**/*.ts"] +} From 9d6a6ef30316f30d0f2f4eb54c67e2883a7e6268 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 17 Dec 2025 07:54:50 +0700 Subject: [PATCH 2/3] #13 feat: add partial matching with Match enum - Add Match enum (Exact, Partial) for path matching strategy - Add partial prop to NavLink component - Update nav_link() function to require explicit Match parameter - Zero-allocation path segment comparison using iterators - Breaking change: nav_link() now requires Match as third parameter - Update examples, documentation, and README - Bump version to 0.4.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 106 +++++---- e2e/tests/nested-routes.spec.ts | 8 + examples/basic/src/main.rs | 4 +- examples/nested-routes/src/main.rs | 5 +- src/lib.rs | 90 ++++---- src/nav_link.rs | 351 +++++++++++++++++++---------- 8 files changed, 367 insertions(+), 201 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a9c4e6..3b2ba19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1094,7 +1094,7 @@ dependencies = [ [[package]] name = "yew-nav-link" -version = "0.3.0" +version = "0.4.0" dependencies = [ "wasm-bindgen-test", "yew", diff --git a/Cargo.toml b/Cargo.toml index 9b0d6c6..d48e78d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yew-nav-link" -version = "0.3.0" +version = "0.4.0" authors = ["RAprogramm "] edition = "2024" rust-version = "1.91.0" diff --git a/README.md b/README.md index 86b2c88..ef06de5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Navigation link component for [Yew](https://yew.rs) with automatic active state - [Usage](#usage) - [Component Syntax](#component-syntax) - [Function Syntax](#function-syntax) + - [Partial Matching](#partial-matching) - [CSS Classes](#css-classes) - [Bootstrap Integration](#bootstrap-integration) - [Tailwind CSS](#tailwind-css) @@ -38,16 +39,16 @@ Navigation link component for [Yew](https://yew.rs) with automatic active state `yew-nav-link` provides a `NavLink` component that wraps Yew Router's `Link` with automatic active state management. When the target route matches the current URL, an `active` CSS class is applied automatically. - + ## Installation ```toml [dependencies] -yew-nav-link = "0.3" +yew-nav-link = "0.4" ``` - + ## Requirements @@ -56,7 +57,7 @@ yew-nav-link = "0.3" | yew | 0.22+ | | yew-router | 0.19+ | - + ## Examples @@ -67,7 +68,7 @@ Full working examples are available in the [examples/](https://github.com/RAprog | [basic](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/basic) | Simple navigation with Home, About, Contact pages | | [bootstrap](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/bootstrap) | Integration with Bootstrap 5 navbar | | [tailwind](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/tailwind) | Sidebar navigation styled with Tailwind CSS | -| [nested-routes](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/nested-routes) | Multi-level navigation with nested routing | +| [nested-routes](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/nested-routes) | Multi-level navigation with partial matching | ### Running Examples @@ -83,7 +84,7 @@ trunk serve Open http://127.0.0.1:8080 in your browser. - + ## Usage @@ -102,18 +103,6 @@ enum Route { About, } -#[component] -fn App() -> Html { - html! { - - -
- render={switch} /> -
-
- } -} - #[component] fn Navigation() -> Html { html! { @@ -123,44 +112,65 @@ fn Navigation() -> Html { } } - -fn switch(route: Route) -> Html { - match route { - Route::Home => html! {

{ "Home" }

}, - Route::About => html! {

{ "About" }

}, - } -} ``` ### Function Syntax -For text-only links, use the `nav_link` helper: +For text-only links, use `nav_link` with explicit `Match` mode: ```rust use yew::prelude::*; -use yew_nav_link::nav_link; +use yew_nav_link::{nav_link, Match}; use yew_router::prelude::*; #[derive(Clone, PartialEq, Routable)] enum Route { #[at("/")] Home, - #[at("/about")] - About, + #[at("/docs")] + Docs, } #[component] fn Menu() -> Html { html! { - + } } ``` - +### Partial Matching + +Use `partial` prop to keep parent links active on nested routes: + +```rust +use yew::prelude::*; +use yew_nav_link::NavLink; +use yew_router::prelude::*; + +#[derive(Clone, PartialEq, Routable)] +enum Route { + #[at("/docs")] + Docs, + #[at("/docs/api")] + DocsApi, +} + +#[component] +fn Navigation() -> Html { + html! { + + } +} +``` + + ## CSS Classes @@ -193,26 +203,34 @@ The component applies these classes to the rendered `` element: } ``` - + ## API Reference ### `NavLink` Component -| Prop | Type | Description | -|------|------|-------------| -| `to` | `R: Routable` | Target route | -| `children` | `Children` | Link content | +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `to` | `R: Routable` | required | Target route | +| `children` | `Children` | required | Link content | +| `partial` | `bool` | `false` | Enable prefix matching | + +### `Match` Enum + +| Variant | Description | +|---------|-------------| +| `Match::Exact` | Active only on exact path match | +| `Match::Partial` | Active when current path starts with target | ### `nav_link` Function ```rust -fn nav_link(to: R, children: &str) -> Html +fn nav_link(to: R, children: &str, match_mode: Match) -> Html ``` -Creates a `NavLink` with text content. +Creates a `NavLink` with text content and specified match mode. - +

Coverage

@@ -249,7 +267,7 @@ The top section represents the entire project. Proceeding with folders and final

- +
@@ -257,4 +275,4 @@ The top section represents the entire project. Proceeding with folders and final Licensed under the [MIT License](LICENSE-MIT). - + diff --git a/e2e/tests/nested-routes.spec.ts b/e2e/tests/nested-routes.spec.ts index bc2eb02..f373e01 100644 --- a/e2e/tests/nested-routes.spec.ts +++ b/e2e/tests/nested-routes.spec.ts @@ -50,6 +50,10 @@ test.describe("Nested Routes Example", () => { await expect(nestedRoutes.subNavLink("Overview")).not.toHaveClass( /active/ ); + // Main nav Documentation stays active due to partial matching + await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass( + /active/ + ); await expect(nestedRoutes.heading()).toHaveText("Getting Started"); }); @@ -125,6 +129,10 @@ test.describe("Nested Routes Example", () => { test("direct URL to nested route works", async ({ nestedRoutes }) => { await nestedRoutes.goto("/docs/api"); + // Main nav stays active due to partial matching + await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass( + /active/ + ); await expect(nestedRoutes.subNavLink("API Reference")).toHaveClass(/active/); await expect(nestedRoutes.heading()).toHaveText("API Reference"); }); diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 5a21d9f..7fedd66 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -3,7 +3,7 @@ //! Run with: `trunk serve` from the examples/basic directory. use yew::prelude::*; -use yew_nav_link::{NavLink, nav_link}; +use yew_nav_link::{Match, NavLink, nav_link}; use yew_router::prelude::*; /// Application routes. @@ -47,7 +47,7 @@ fn Navigation() -> Html { to={Route::About}>{ "About" }> // Method 2: Function syntax (convenient for text-only links) -
  • { nav_link(Route::Contact, "Contact") }
  • +
  • { nav_link(Route::Contact, "Contact", Match::Exact) }
  • } diff --git a/examples/nested-routes/src/main.rs b/examples/nested-routes/src/main.rs index a7d5497..7d0b260 100644 --- a/examples/nested-routes/src/main.rs +++ b/examples/nested-routes/src/main.rs @@ -67,14 +67,15 @@ fn App() -> Html { } /// Main navigation bar. +/// Uses `partial=true` so section links stay active on nested routes. #[component] fn MainNav() -> Html { html! { } diff --git a/src/lib.rs b/src/lib.rs index e1c7395..ea1aea4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! # yew-nav-link //! -//! A navigation link component for [Yew](https://yew.rs) applications with -//! automatic active state detection based on the current route. +//! Navigation link component for [Yew](https://yew.rs) with automatic active +//! state detection. //! //! [![Crates.io](https://img.shields.io/crates/v/yew-nav-link)](https://crates.io/crates/yew-nav-link) //! [![Documentation](https://docs.rs/yew-nav-link/badge.svg)](https://docs.rs/yew-nav-link) @@ -10,20 +10,17 @@ //! ## Overview //! //! `yew-nav-link` provides a [`NavLink`] component that wraps Yew Router's -//! `Link` component with automatic active state management. When the link's -//! target route matches the current URL, an `active` CSS class is automatically -//! applied. +//! `Link` with automatic active state management. When the target route matches +//! the current URL, an `active` CSS class is applied. //! //! ## Quick Start //! -//! Add to your `Cargo.toml`: -//! //! ```toml //! [dependencies] -//! yew-nav-link = "0.3" +//! yew-nav-link = "0.4" //! ``` //! -//! ## Usage +//! ## Component Syntax //! //! ```rust //! use yew::prelude::*; @@ -39,55 +36,76 @@ //! } //! //! #[component] -//! fn App() -> Html { +//! fn Navigation() -> Html { //! html! { -//! -//! -//! render={switch} /> -//! -//! } -//! } -//! -//! fn switch(route: Route) -> Html { -//! match route { -//! Route::Home => html! {

    { "Home" }

    }, -//! Route::About => html! {

    { "About" }

    } +//! //! } //! } //! ``` //! -//! ## Helper Function +//! ## Function Syntax //! -//! For text-only links, use the [`nav_link`] helper function: +//! For text-only links, use [`nav_link`] with explicit [`Match`] mode: //! //! ```rust //! use yew::prelude::*; -//! use yew_nav_link::nav_link; +//! use yew_nav_link::{Match, nav_link}; //! use yew_router::prelude::*; //! //! # #[derive(Clone, PartialEq, Debug, Routable)] //! # enum Route { //! # #[at("/")] //! # Home, +//! # #[at("/docs")] +//! # Docs, //! # } //! #[component] //! fn Menu() -> Html { //! html! { -//! +//! +//! } +//! } +//! ``` +//! +//! ## Partial Matching +//! +//! Use `partial` prop to keep parent links active on nested routes: +//! +//! ```rust +//! use yew::prelude::*; +//! use yew_nav_link::NavLink; +//! use yew_router::prelude::*; +//! +//! # #[derive(Clone, PartialEq, Debug, Routable)] +//! # enum Route { +//! # #[at("/docs")] +//! # Docs, +//! # #[at("/docs/api")] +//! # DocsApi, +//! # } +//! #[component] +//! fn Navigation() -> Html { +//! html! { +//! //! } //! } //! ``` //! //! ## CSS Classes //! -//! The rendered `` element receives: -//! - `nav-link` - always applied -//! - `active` - applied when route matches current URL +//! | Class | Condition | +//! |-------|-----------| +//! | `nav-link` | Always | +//! | `active` | Route matches | //! //! Compatible with Bootstrap, Tailwind, and other CSS frameworks. //! @@ -95,11 +113,7 @@ //! //! - Yew 0.22+ //! - yew-router 0.19+ -//! -//! ## License -//! -//! Licensed under the MIT License. See [LICENSE-MIT](LICENSE-MIT) for details. mod nav_link; -pub use nav_link::{NavLink, NavLinkProps, nav_link}; +pub use nav_link::{Match, NavLink, NavLinkProps, nav_link}; diff --git a/src/nav_link.rs b/src/nav_link.rs index ff54cbd..cede33a 100644 --- a/src/nav_link.rs +++ b/src/nav_link.rs @@ -18,126 +18,114 @@ //! //! # CSS Classes //! -//! The component applies the following CSS classes to the rendered `` -//! element: -//! //! | Class | Condition | //! |-------|-----------| //! | `nav-link` | Always applied | //! | `active` | Applied when the target route matches the current route | //! -//! # Usage +//! # Match Modes +//! +//! NavLink supports two matching modes via the `partial` prop: //! -//! ## Component Syntax +//! - **Exact** (default): Link is active only when paths match exactly +//! - **Partial**: Link is active when current path starts with target path //! //! ```rust //! use yew::prelude::*; //! use yew_nav_link::NavLink; //! use yew_router::prelude::*; //! -//! #[derive(Clone, PartialEq, Debug, Routable)] +//! #[derive(Clone, PartialEq, Routable)] //! enum Route { -//! #[at("/")] -//! Home, -//! #[at("/about")] -//! About +//! #[at("/docs")] +//! Docs, +//! #[at("/docs/api")] +//! DocsApi //! } //! //! #[component] //! fn Navigation() -> Html { //! html! { //! //! } //! } //! ``` //! -//! ## Function Syntax +//! # Function Syntax //! -//! For simpler cases with text-only children: +//! For text-only links, use [`nav_link`] with explicit [`Match`] mode: //! //! ```rust //! use yew::prelude::*; -//! use yew_nav_link::nav_link; +//! use yew_nav_link::{Match, nav_link}; //! use yew_router::prelude::*; //! //! #[derive(Clone, PartialEq, Debug, Routable)] //! enum Route { //! #[at("/")] //! Home, -//! #[at("/about")] -//! About +//! #[at("/docs")] +//! Docs //! } //! //! #[component] //! fn Navigation() -> Html { //! html! { //! //! } //! } //! ``` -//! -//! # Integration with CSS Frameworks -//! -//! The component works seamlessly with Bootstrap, Tailwind, and other CSS -//! frameworks that use the `.nav-link` and `.active` class conventions: -//! -//! ```html -//! -//! -//! ``` use std::marker::PhantomData; use yew::prelude::*; use yew_router::prelude::*; +/// Path matching strategy for NavLink active state detection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Match { + /// Link is active only when paths match exactly. + #[default] + Exact, + /// Link is active when current path starts with target path (segment-wise). + Partial +} + /// Properties for the [`NavLink`] component. -/// -/// # Type Parameters -/// -/// * `R` - A type implementing [`Routable`] that defines the target route. #[derive(Properties, PartialEq, Debug)] pub struct NavLinkProps { /// Target route for navigation. - /// - /// When clicked, the application navigates to this route. - /// The component compares this value against the current route - /// to determine active state. pub to: R, /// Content rendered inside the link element. - /// - /// Accepts any valid Yew children: text, HTML elements, or components. pub children: Children, + /// Enable partial (prefix) path matching. + /// + /// When `false` (default), the link is active only on exact path match. + /// When `true`, the link is active if current path starts with target path. + #[prop_or(false)] + pub partial: bool, + #[prop_or_default] pub(crate) _marker: PhantomData } /// Navigation link with automatic active state detection. /// -/// Wraps Yew Router's [`Link`] component and automatically applies the `active` -/// CSS class when the target route matches the current URL. -/// /// # CSS Classes /// /// - `nav-link` - Always applied /// - `active` - Applied when route matches current URL /// -/// # Type Parameters -/// -/// * `R` - Route type implementing [`Routable`] -/// /// # Example /// /// ```rust @@ -166,84 +154,98 @@ pub struct NavLinkProps { #[component] pub fn NavLink(props: &NavLinkProps) -> Html { let current_route = use_route::(); - let is_active = current_route.is_some_and(|route| route == props.to); - let class = build_class(is_active); + let is_active = current_route.is_some_and(|route| { + if props.partial { + is_path_prefix(&props.to.to_path(), &route.to_path()) + } else { + route == props.to + } + }); + html! { - to={props.to.clone()} classes={classes!(class)}> - for child in props.children.iter() { - { child } - } + to={props.to.clone()} classes={classes!(build_class(is_active))}> + { for props.children.iter() } > } } -/// Creates a NavLink component for the specified route with the provided -/// children. -/// -/// This function creates a NavLink component for Yew applications using Yew -/// Router. It takes a route (`R`) and children text, and returns a NavLink -/// component. +/// Creates a NavLink with the specified match mode. /// /// # Arguments /// -/// * `to` - The destination route for the link. -/// * `children` - The text or other elements to be rendered within the link. +/// * `to` - Target route +/// * `children` - Link text +/// * `match_mode` - [`Match::Exact`] or [`Match::Partial`] /// /// # Example /// /// ```rust /// use yew::prelude::*; -/// use yew_nav_link::{NavLink, nav_link}; +/// use yew_nav_link::{Match, nav_link}; /// use yew_router::prelude::*; /// /// #[derive(Clone, PartialEq, Debug, Routable)] -/// enum HomeRoute { +/// enum Route { /// #[at("/")] -/// IntroPage, -/// #[at("/about")] -/// About +/// Home, +/// #[at("/docs")] +/// Docs /// } /// /// #[component] /// fn Menu() -> Html { /// html! { -/// +/// /// } /// } /// ``` +pub fn nav_link( + to: R, + children: &str, + match_mode: Match +) -> Html { + let partial = match_mode == Match::Partial; + html! { + to={to} {partial}>{ Html::from(children) }> + } +} + +/// Checks if `target` path is a segment-wise prefix of `current` path. /// -/// # Generic Type -/// -/// * `R` - The route type that implements the `Routable` trait. -/// -/// # Returns -/// -/// An HTML representation of the NavLink component. +/// Uses iterators without heap allocation for efficiency during renders. /// -/// # Note +/// # Examples /// -/// The `to` parameter must be of a type that implements the `Routable` trait. -pub fn nav_link(to: R, children: &str) -> Html { - html! { - to={to}>{ Html::from(children) }> +/// ```text +/// is_path_prefix("/docs", "/docs/api") -> true +/// is_path_prefix("/docs", "/docs") -> true +/// is_path_prefix("/doc", "/documents") -> false (segment boundary) +/// is_path_prefix("/", "/anything") -> true +/// ``` +#[inline] +fn is_path_prefix(target: &str, current: &str) -> bool { + let mut target_iter = target.split('/').filter(|s| !s.is_empty()); + let mut current_iter = current.split('/').filter(|s| !s.is_empty()); + + loop { + match (target_iter.next(), current_iter.next()) { + (Some(t), Some(c)) if t == c => continue, + (Some(_), Some(_)) => return false, + (Some(_), None) => return false, + (None, _) => return true + } } } -/// Generates CSS class string based on active state. #[inline] -fn build_class(is_active: bool) -> String { +fn build_class(is_active: bool) -> &'static str { if is_active { - "nav-link active".to_string() + "nav-link active" } else { - "nav-link".to_string() + "nav-link" } } @@ -256,9 +258,40 @@ mod tests { #[at("/")] Home, #[at("/about")] - About + About, + #[at("/docs")] + Docs, + #[at("/docs/api")] + DocsApi + } + + // Match enum tests + #[test] + fn match_default_is_exact() { + assert_eq!(Match::default(), Match::Exact); + } + + #[test] + fn match_equality() { + assert_eq!(Match::Exact, Match::Exact); + assert_eq!(Match::Partial, Match::Partial); + assert_ne!(Match::Exact, Match::Partial); + } + + #[test] + fn match_debug() { + assert_eq!(format!("{:?}", Match::Exact), "Exact"); + assert_eq!(format!("{:?}", Match::Partial), "Partial"); + } + + #[test] + fn match_clone() { + let m = Match::Partial; + let cloned = m; + assert_eq!(m, cloned); } + // build_class tests #[test] fn build_class_active() { assert_eq!(build_class(true), "nav-link active"); @@ -269,77 +302,169 @@ mod tests { assert_eq!(build_class(false), "nav-link"); } + // NavLinkProps tests #[test] - fn props_equality_same_route() { + fn props_equality_same() { let props1: NavLinkProps = NavLinkProps { to: TestRoute::Home, children: Default::default(), + partial: false, _marker: PhantomData }; let props2: NavLinkProps = NavLinkProps { to: TestRoute::Home, children: Default::default(), + partial: false, _marker: PhantomData }; assert_eq!(props1, props2); } #[test] - fn props_equality_different_routes() { + fn props_equality_different_route() { let props1: NavLinkProps = NavLinkProps { to: TestRoute::Home, children: Default::default(), + partial: false, _marker: PhantomData }; let props2: NavLinkProps = NavLinkProps { to: TestRoute::About, children: Default::default(), + partial: false, + _marker: PhantomData + }; + assert_ne!(props1, props2); + } + + #[test] + fn props_equality_different_partial() { + let props1: NavLinkProps = NavLinkProps { + to: TestRoute::Home, + children: Default::default(), + partial: false, + _marker: PhantomData + }; + let props2: NavLinkProps = NavLinkProps { + to: TestRoute::Home, + children: Default::default(), + partial: true, _marker: PhantomData }; assert_ne!(props1, props2); } #[test] - fn props_debug_impl() { + fn props_debug() { let props: NavLinkProps = NavLinkProps { to: TestRoute::Home, children: Default::default(), + partial: false, _marker: PhantomData }; - let debug_str = format!("{:?}", props); - assert!(debug_str.contains("NavLinkProps")); - assert!(debug_str.contains("Home")); + let debug = format!("{:?}", props); + assert!(debug.contains("NavLinkProps")); + assert!(debug.contains("Home")); } + // nav_link function tests #[test] - fn nav_link_fn_returns_html() { - let html = nav_link(TestRoute::Home, "Home"); + fn nav_link_exact_returns_html() { + let html = nav_link(TestRoute::Home, "Home", Match::Exact); assert!(matches!(html, Html::VComp(_))); } #[test] - fn nav_link_fn_different_routes() { - let html1 = nav_link(TestRoute::Home, "Home"); - let html2 = nav_link(TestRoute::About, "About"); - assert!(matches!(html1, Html::VComp(_))); - assert!(matches!(html2, Html::VComp(_))); + fn nav_link_partial_returns_html() { + let html = nav_link(TestRoute::Docs, "Docs", Match::Partial); + assert!(matches!(html, Html::VComp(_))); } #[test] - fn nav_link_fn_empty_text() { - let html = nav_link(TestRoute::Home, ""); - assert!(matches!(html, Html::VComp(_))); + fn nav_link_different_routes() { + let h1 = nav_link(TestRoute::Home, "Home", Match::Exact); + let h2 = nav_link(TestRoute::About, "About", Match::Exact); + assert!(matches!(h1, Html::VComp(_))); + assert!(matches!(h2, Html::VComp(_))); } #[test] - fn nav_link_fn_long_text() { - let html = nav_link(TestRoute::Home, "This is a very long navigation link text"); + fn nav_link_empty_text() { + let html = nav_link(TestRoute::Home, "", Match::Exact); assert!(matches!(html, Html::VComp(_))); } + // is_path_prefix tests - exact matches + #[test] + fn prefix_exact_match() { + assert!(is_path_prefix("/", "/")); + assert!(is_path_prefix("/docs", "/docs")); + assert!(is_path_prefix("/docs/api", "/docs/api")); + } + + // is_path_prefix tests - valid prefixes + #[test] + fn prefix_valid() { + assert!(is_path_prefix("/docs", "/docs/api")); + assert!(is_path_prefix("/docs", "/docs/api/ref")); + assert!(is_path_prefix("/a", "/a/b/c/d")); + } + + #[test] + fn prefix_root_matches_all() { + assert!(is_path_prefix("/", "/docs")); + assert!(is_path_prefix("/", "/docs/api")); + assert!(is_path_prefix("/", "/any/path/here")); + } + + // is_path_prefix tests - not prefixes + #[test] + fn prefix_not_prefix() { + assert!(!is_path_prefix("/docs/api", "/docs")); + assert!(!is_path_prefix("/about", "/docs")); + assert!(!is_path_prefix("/a/b/c", "/a/b")); + } + + #[test] + fn prefix_segment_boundary() { + assert!(!is_path_prefix("/doc", "/documents")); + assert!(!is_path_prefix("/api", "/api-v2")); + assert!(!is_path_prefix("/user", "/users")); + } + + // is_path_prefix tests - edge cases + #[test] + fn prefix_trailing_slashes() { + assert!(is_path_prefix("/docs/", "/docs/api")); + assert!(is_path_prefix("/docs", "/docs/api/")); + assert!(is_path_prefix("/docs/", "/docs/")); + } + + #[test] + fn prefix_multiple_slashes() { + assert!(is_path_prefix("/docs//", "/docs/api")); + assert!(is_path_prefix("//docs", "/docs//api")); + } + + #[test] + fn prefix_empty_paths() { + assert!(is_path_prefix("", "/docs")); + assert!(is_path_prefix("", "")); + assert!(!is_path_prefix("/docs", "")); + } + + // Route tests #[test] fn route_equality() { assert_eq!(TestRoute::Home, TestRoute::Home); assert_ne!(TestRoute::Home, TestRoute::About); } + + #[test] + fn route_to_path() { + assert_eq!(TestRoute::Home.to_path(), "/"); + assert_eq!(TestRoute::About.to_path(), "/about"); + assert_eq!(TestRoute::Docs.to_path(), "/docs"); + assert_eq!(TestRoute::DocsApi.to_path(), "/docs/api"); + } } From 5ff14d66d3cffefa102273d0f70557821d31a0d7 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 17 Dec 2025 08:12:24 +0700 Subject: [PATCH 3/3] #13 fix: install rustup in release script --- .rultor.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.rultor.yml b/.rultor.yml index 3d057cb..3fafc18 100644 --- a/.rultor.yml +++ b/.rultor.yml @@ -13,6 +13,8 @@ release: commanders: - RAprogramm script: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source $HOME/.cargo/env rustup toolchain install nightly rustup component add rustfmt --toolchain nightly sed -i "s/^version = .*/version = \"${tag}\"/" Cargo.toml