Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions etch/src/svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ impl Default for SvgOptions {
/// The returned string is a complete, self-contained `<svg>` document
/// suitable for embedding in HTML or writing to a `.svg` file.
pub fn render_svg(layout: &GraphLayout, options: &SvgOptions) -> String {
let pad = options.padding;
// Extra padding for port labels that extend beyond node boundaries.
let port_label_margin = 60.0;
let pad = options.padding + port_label_margin;
let vb_w = layout.width + pad * 2.0;
let vb_h = layout.height + pad * 2.0;

Expand Down Expand Up @@ -99,11 +101,12 @@ pub fn render_svg(layout: &GraphLayout, options: &SvgOptions) -> String {
// Translate everything by padding.
writeln!(svg, " <g transform=\"translate({pad},{pad})\">").unwrap();

// Edges layer.
// Render order: containers → edges → leaf nodes.
// Containers are backgrounds; edges paint on top of them;
// leaf nodes paint on top of edges.
write_nodes(&mut svg, layout, options, true); // containers only
write_edges(&mut svg, layout);

// Nodes layer.
write_nodes(&mut svg, layout, options);
write_nodes(&mut svg, layout, options, false); // leaves only

svg.push_str(" </g>\n");
svg.push_str("</svg>\n");
Expand Down Expand Up @@ -216,18 +219,13 @@ fn write_edges(svg: &mut String, layout: &GraphLayout) {
svg.push_str(" </g>\n");
}

fn write_nodes(svg: &mut String, layout: &GraphLayout, options: &SvgOptions) {
svg.push_str(" <g class=\"nodes\">\n");
fn write_nodes(svg: &mut String, layout: &GraphLayout, options: &SvgOptions, containers: bool) {
let class = if containers { "containers" } else { "nodes" };
writeln!(svg, " <g class=\"{class}\">").unwrap();

let default_fill = "#e8e8e8".to_string();

// Draw containers first (background), then leaf nodes on top.
let containers: Vec<&crate::layout::LayoutNode> =
layout.nodes.iter().filter(|n| n.is_container).collect();
let leaves: Vec<&crate::layout::LayoutNode> =
layout.nodes.iter().filter(|n| !n.is_container).collect();

for node in containers.iter().chain(leaves.iter()) {
for node in layout.nodes.iter().filter(|n| n.is_container == containers) {
let fill = options
.type_colors
.get(&node.node_type)
Expand Down
108 changes: 108 additions & 0 deletions tests/playwright/coverage-view.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { test, expect } from "@playwright/test";
import { waitForHtmx } from "./helpers";

test.describe("Coverage View", () => {
test("coverage page loads with heading", async ({ page }) => {
await page.goto("/coverage");
await waitForHtmx(page);
await expect(page.locator("h2")).toContainText("Traceability Coverage");
});

test("shows overall coverage percentage", async ({ page }) => {
await page.goto("/coverage");
await waitForHtmx(page);
// The stat grid should show an overall coverage percentage
const statGrid = page.locator(".stat-grid");
await expect(statGrid).toBeVisible();
await expect(statGrid).toContainText("Overall Coverage");
});

test("shows coverage rules count", async ({ page }) => {
await page.goto("/coverage");
await waitForHtmx(page);
await expect(page.locator(".stat-grid")).toContainText("Rules");
});

test("shows coverage table with rule details", async ({ page }) => {
await page.goto("/coverage");
await waitForHtmx(page);
const table = page.locator("table");
const tableCount = await table.count();
if (tableCount === 0) {
// No traceability rules defined — the card message should explain
await expect(page.locator("body")).toContainText(
"No traceability rules",
);
return;
}
// Table should have expected columns
await expect(table.locator("thead")).toContainText("Rule");
await expect(table.locator("thead")).toContainText("Source Type");
await expect(table.locator("thead")).toContainText("Coverage");
});

test("coverage bars have progress indicators", async ({ page }) => {
await page.goto("/coverage");
await waitForHtmx(page);
const table = page.locator("table");
const tableCount = await table.count();
if (tableCount === 0) {
test.skip();
return;
}
// Each row should have a progress bar div
const rows = page.locator("table tbody tr");
const rowCount = await rows.count();
expect(rowCount).toBeGreaterThan(0);
});

test("coverage badges link to artifact types", async ({ page }) => {
await page.goto("/coverage");
await waitForHtmx(page);
const table = page.locator("table");
const tableCount = await table.count();
if (tableCount === 0) {
test.skip();
return;
}
// Link pills should be visible for link types
const linkPills = page.locator(".link-pill");
const pillCount = await linkPills.count();
expect(pillCount).toBeGreaterThan(0);
});

test("uncovered artifacts section shows artifact links", async ({
page,
}) => {
await page.goto("/coverage");
await waitForHtmx(page);
const uncoveredSection = page.locator("text=Uncovered Artifacts");
const count = await uncoveredSection.count();
if (count === 0) {
// All artifacts are covered — no uncovered section expected
return;
}
await expect(uncoveredSection).toBeVisible();
// Uncovered artifact IDs should be clickable links
const artifactLinks = page.locator("a[hx-get^='/artifacts/']");
const linkCount = await artifactLinks.count();
expect(linkCount).toBeGreaterThan(0);
});

test("coverage page has no JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));

await page.goto("/coverage");
await waitForHtmx(page);
await page.waitForLoadState("networkidle");

const realErrors = errors.filter(
(e) =>
!e.includes("spar_wasm") &&
!e.includes("AADL WASM") &&
!e.includes("mermaid"),
);
expect(realErrors).toEqual([]);
});
});
215 changes: 215 additions & 0 deletions tests/playwright/filter-sort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { test, expect } from "@playwright/test";
import { countTableRows, waitForHtmx } from "./helpers";

test.describe("Artifacts Filter/Sort/Pagination", () => {
test("?types=requirement filters to only requirements", async ({
page,
}) => {
await page.goto("/artifacts?types=requirement");
await waitForHtmx(page);
const rows = await countTableRows(page);
expect(rows).toBeGreaterThan(0);

// All visible type badges should be "requirement"
const typeCells = await page
.locator("table tbody tr td:nth-child(2)")
.allTextContents();
for (const cell of typeCells) {
expect(cell.toLowerCase().trim()).toBe("requirement");
}
});

test("?q=OSLC searches and filters results", async ({ page }) => {
await page.goto("/artifacts?q=OSLC");
await waitForHtmx(page);
const rows = await countTableRows(page);
expect(rows).toBeGreaterThan(0);
// At least one artifact should mention OSLC in its id or title
const body = await page.locator("table tbody").textContent();
expect(body!.toLowerCase()).toContain("oslc");
});

test("?sort=type&dir=asc sorts by type ascending", async ({ page }) => {
await page.goto("/artifacts?sort=type&dir=asc");
await waitForHtmx(page);
await expect(page).toHaveURL(/sort=type/);
await expect(page).toHaveURL(/dir=asc/);
const rows = await countTableRows(page);
expect(rows).toBeGreaterThan(0);
});

test("?sort=id&dir=desc sorts by id descending", async ({ page }) => {
await page.goto("/artifacts?sort=id&dir=desc");
await waitForHtmx(page);
await expect(page).toHaveURL(/sort=id/);
await expect(page).toHaveURL(/dir=desc/);
});

test("?per_page=10 limits rows to 10", async ({ page }) => {
await page.goto("/artifacts?per_page=10");
await waitForHtmx(page);
const rows = await countTableRows(page);
expect(rows).toBeLessThanOrEqual(10);
expect(rows).toBeGreaterThan(0);
});

test("?page=2 shows second page", async ({ page }) => {
// Use a small per_page to ensure there is a page 2
await page.goto("/artifacts?per_page=10&page=2");
await waitForHtmx(page);
const rows = await countTableRows(page);
expect(rows).toBeGreaterThan(0);
await expect(page).toHaveURL(/page=2/);
});

test("pagination controls have correct href (not #)", async ({ page }) => {
await page.goto("/artifacts?per_page=10&page=1");
await waitForHtmx(page);

const paginationLinks = page.locator(".pagination a");
const count = await paginationLinks.count();
if (count === 0) {
// Not enough artifacts for pagination
test.skip();
return;
}

const hrefs = await paginationLinks.evaluateAll((els) =>
els.map((el) => el.getAttribute("href")),
);
for (const href of hrefs) {
expect(href).not.toBe("#");
expect(href).toBeTruthy();
}
});

test("pagination links include existing filter params", async ({
page,
}) => {
await page.goto("/artifacts?types=requirement&per_page=10&page=1");
await waitForHtmx(page);

const paginationLinks = page.locator(".pagination a");
const count = await paginationLinks.count();
if (count === 0) {
test.skip();
return;
}

// Pagination links should preserve the types filter
const hrefs = await paginationLinks.evaluateAll((els) =>
els.map((el) => el.getAttribute("href")).filter(Boolean),
);
for (const href of hrefs) {
expect(href).toContain("types=requirement");
}
});

test("filter bar search input is present", async ({ page }) => {
await page.goto("/artifacts");
await waitForHtmx(page);
const searchInput = page.locator(
".filter-bar input[name='q'], .filter-bar input[type='search']",
);
await expect(searchInput).toBeVisible();
});

test("filter bar search input has HTMX trigger", async ({ page }) => {
await page.goto("/artifacts");
await waitForHtmx(page);
const searchInput = page.locator(
".filter-bar input[name='q'], .filter-bar input[type='search']",
);
const hxTrigger = await searchInput.getAttribute("hx-trigger");
expect(hxTrigger).toContain("keyup");
});

test("type dropdown is present in filter bar", async ({ page }) => {
await page.goto("/artifacts");
await waitForHtmx(page);
// Type filter could be checkboxes or select
const typeFilter = page.locator(
".filter-bar select[name='types'], .filter-bar input[name='types']",
);
const count = await typeFilter.count();
expect(count).toBeGreaterThan(0);
});

test("per_page select is present in filter bar", async ({ page }) => {
await page.goto("/artifacts");
await waitForHtmx(page);
const perPageSelect = page.locator(".filter-bar select[name='per_page']");
await expect(perPageSelect).toBeVisible();
});

test("per_page select has standard options", async ({ page }) => {
await page.goto("/artifacts");
await waitForHtmx(page);
const perPageSelect = page.locator("select[name='per_page']");
// Should have options for 25, 50, 100, 200
// Note: <option> elements inside a <select> are considered "hidden" by Playwright,
// so we use toBeAttached() instead of toBeVisible().
await expect(perPageSelect.locator("option[value='25']")).toBeAttached();
await expect(perPageSelect.locator("option[value='50']")).toBeAttached();
await expect(perPageSelect.locator("option[value='100']")).toBeAttached();
await expect(perPageSelect.locator("option[value='200']")).toBeAttached();
});

test("combined params: ?types=requirement&q=STPA&sort=id&per_page=20", async ({
page,
}) => {
await page.goto(
"/artifacts?types=requirement&q=STPA&sort=id&per_page=20",
);
await waitForHtmx(page);
await expect(page).toHaveURL(/types=requirement/);
await expect(page).toHaveURL(/sort=id/);
// Results should be filtered (may be 0 if no requirement mentions STPA)
const body = await page.locator("body").textContent();
expect(body!.length).toBeGreaterThan(100);
});

test("sort headers in table are clickable", async ({ page }) => {
await page.goto("/artifacts");
await waitForHtmx(page);
// Table headers should be clickable links with sort params
const sortableHeaders = page.locator("thead a[hx-get]");
const count = await sortableHeaders.count();
expect(count).toBeGreaterThan(0);

// Each sort header should have an href with sort param
const hrefs = await sortableHeaders.evaluateAll((els) =>
els.map((el) => el.getAttribute("href")).filter(Boolean),
);
for (const href of hrefs) {
expect(href).toContain("sort=");
}
});

test("page reload preserves filter state", async ({ page }) => {
await page.goto("/artifacts?types=feature&sort=id&dir=asc&per_page=25");
await waitForHtmx(page);
await page.reload();
await expect(page).toHaveURL(/types=feature/);
await expect(page).toHaveURL(/sort=id/);
await expect(page).toHaveURL(/dir=asc/);
await expect(page).toHaveURL(/per_page=25/);
});

test("filter bar has no JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));

await page.goto("/artifacts?types=requirement&q=test&sort=id&per_page=10");
await waitForHtmx(page);
await page.waitForLoadState("networkidle");

const realErrors = errors.filter(
(e) =>
!e.includes("spar_wasm") &&
!e.includes("AADL WASM") &&
!e.includes("mermaid"),
);
expect(realErrors).toEqual([]);
});
});
Loading
Loading