feat(web): add data-backed planning overview
Build CutList image / build-and-push (push) Successful in 19s
Build CutList image / build-and-push (push) Successful in 19s
This commit is contained in:
@@ -101,6 +101,8 @@ CutList.Mcp is an stdio MCP server, not a hosted service — it's published to `
|
||||
|
||||
**Job title**: The job-editor page uses the `job-title` class to keep long job names at a compact, readable heading size without changing the larger dashboard hero typography.
|
||||
|
||||
**Overview**: The root page uses `OverviewService` to show recently created jobs, headline planning counts, and the five stock configurations most frequently specified across job stock. The stock ranking is a planning-demand signal (distinct jobs configured for a material/length), not a count of on-hand inventory.
|
||||
|
||||
**Material list semantics**: The Results tab labels lengths not covered by the job's configured stock as a **Material List**, not a purchase list. It identifies required material; purchasing remains a separate decision outside the cut-list result.
|
||||
|
||||
### CutList.Mcp — MCP Server
|
||||
|
||||
@@ -1,49 +1,148 @@
|
||||
@page "/"
|
||||
@inject OverviewService OverviewService
|
||||
@using CutList.Core.Formatting
|
||||
|
||||
<PageTitle>CutList - Home</PageTitle>
|
||||
<PageTitle>CutList - Overview</PageTitle>
|
||||
|
||||
<section class="dashboard-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Production planning</p>
|
||||
<h1>Make every length count.</h1>
|
||||
<p class="hero-copy">Build a cut plan, optimize the stock, and send a clear list to the shop floor.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> New job</a>
|
||||
<a href="jobs" class="btn btn-quiet">View all jobs <i class="bi bi-arrow-up-right"></i></a>
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading overview…</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="overview-header">
|
||||
<div>
|
||||
<p class="eyebrow">Production planning</p>
|
||||
<h1>Cut planning at a glance.</h1>
|
||||
<p>See what is moving through the shop and the stock configurations that recur across jobs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-graphic" aria-hidden="true">
|
||||
<span class="cut-line line-one"></span><span class="cut-line line-two"></span><span class="cut-line line-three"></span>
|
||||
<b>01</b>
|
||||
</div>
|
||||
</section>
|
||||
<div class="overview-actions">
|
||||
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
|
||||
<a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">Start here</p><h2>Set up your workspace</h2></div>
|
||||
<span class="section-note">Three things to keep production moving</span>
|
||||
</div>
|
||||
<div class="action-grid">
|
||||
<a href="jobs" class="action-card action-card-featured">
|
||||
<span class="card-number">01</span><i class="bi bi-layers"></i><h3>Plan a job</h3>
|
||||
<p>Add required cuts, choose stock, then optimize the layout.</p><span class="card-link">Open jobs <i class="bi bi-arrow-right"></i></span>
|
||||
</a>
|
||||
<a href="materials" class="action-card">
|
||||
<span class="card-number">02</span><i class="bi bi-box-seam"></i><h3>Material library</h3>
|
||||
<p>Keep profiles, grades, and dimensions ready to reuse.</p><span class="card-link">Manage materials <i class="bi bi-arrow-right"></i></span>
|
||||
</a>
|
||||
<a href="tools" class="action-card">
|
||||
<span class="card-number">03</span><i class="bi bi-tools"></i><h3>Cutting tools</h3>
|
||||
<p>Set kerf values so the numbers match the real cut.</p><span class="card-link">Configure tools <i class="bi bi-arrow-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@if (loadError is not null)
|
||||
{
|
||||
<div class="alert alert-warning mt-4" role="alert">
|
||||
<strong>Overview data is unavailable.</strong> @loadError
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="workflow-panel">
|
||||
<div><p class="eyebrow">From estimate to shop floor</p><h2>A simple, reliable run of work.</h2></div>
|
||||
<ol class="workflow-list">
|
||||
<li><span>1</span><div><strong>Define material</strong><small>Profile, size, and grade</small></div></li>
|
||||
<li><span>2</span><div><strong>Add stock & parts</strong><small>Lengths, quantities, and kerf</small></div></li>
|
||||
<li><span>3</span><div><strong>Optimize & print</strong><small>Efficient cuts, ready to run</small></div></li>
|
||||
</ol>
|
||||
</section>
|
||||
<section class="overview-stats" aria-label="CutList statistics">
|
||||
<a href="jobs" class="overview-stat">
|
||||
<span class="overview-stat-label">Jobs</span>
|
||||
<strong>@overview.TotalJobs</strong>
|
||||
<small>@overview.OpenJobs open for planning</small>
|
||||
</a>
|
||||
<a href="jobs" class="overview-stat">
|
||||
<span class="overview-stat-label">Parts required</span>
|
||||
<strong>@overview.TotalParts</strong>
|
||||
<small>Across all current jobs</small>
|
||||
</a>
|
||||
<a href="stock" class="overview-stat">
|
||||
<span class="overview-stat-label">Stock options</span>
|
||||
<strong>@overview.ActiveStockItems</strong>
|
||||
<small>Active catalog lengths</small>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="overview-grid">
|
||||
<article class="overview-panel overview-recent-jobs">
|
||||
<div class="overview-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Work queue</p>
|
||||
<h2>Recently created jobs</h2>
|
||||
</div>
|
||||
<a href="jobs">View all <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
@if (overview.RecentJobs.Count == 0)
|
||||
{
|
||||
<div class="overview-empty">
|
||||
<i class="bi bi-layers" aria-hidden="true"></i>
|
||||
<p>No jobs yet. Create a job to begin building your planning history.</p>
|
||||
<a href="jobs/new" class="btn btn-primary">Create first job</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overview-job-list">
|
||||
@foreach (var job in overview.RecentJobs)
|
||||
{
|
||||
<a href="jobs/@job.Id" class="overview-job-row">
|
||||
<span class="overview-job-id">@job.JobNumber</span>
|
||||
<span class="overview-job-name">@(string.IsNullOrWhiteSpace(job.Name) ? "Untitled job" : job.Name)</span>
|
||||
<span class="overview-job-meta">@(string.IsNullOrWhiteSpace(job.Customer) ? "No customer" : job.Customer) · @job.PartCount @(job.PartCount == 1 ? "part" : "parts")</span>
|
||||
<time datetime="@job.CreatedAt.ToString("O")">@job.CreatedAt.ToLocalTime().ToString("MMM d")</time>
|
||||
@if (job.IsLocked)
|
||||
{
|
||||
<i class="bi bi-lock-fill overview-lock" title="Materials ordered" aria-label="Materials ordered"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-right overview-row-arrow" aria-hidden="true"></i>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="overview-panel overview-stock-panel">
|
||||
<div class="overview-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Planning signal</p>
|
||||
<h2>Frequently specified stock</h2>
|
||||
</div>
|
||||
<a href="stock">Manage stock <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
<p class="overview-panel-note">Based on stock configured on jobs. This shows recurring planning demand, not on-hand inventory.</p>
|
||||
@if (overview.FrequentStock.Count == 0)
|
||||
{
|
||||
<div class="overview-empty overview-empty-compact">
|
||||
<i class="bi bi-box-seam" aria-hidden="true"></i>
|
||||
<p>Add stock to jobs to reveal the configurations used most often.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ol class="overview-stock-list">
|
||||
@for (var stockIndex = 0; stockIndex < overview.FrequentStock.Count; stockIndex++)
|
||||
{
|
||||
var stock = overview.FrequentStock[stockIndex];
|
||||
<li>
|
||||
<span class="overview-stock-rank">@((stockIndex + 1).ToString("D2"))</span>
|
||||
<div>
|
||||
<strong>@stock.MaterialName</strong>
|
||||
<span>@ArchUnits.FormatFromInches((double)stock.LengthInches) stock length</span>
|
||||
</div>
|
||||
<span class="overview-stock-count">@stock.JobCount <small>@(stock.JobCount == 1 ? "job" : "jobs")</small></span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private OverviewSnapshot overview = new(0, 0, 0, 0, [], []);
|
||||
private bool loading = true;
|
||||
private string? loadError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
overview = await OverviewService.GetSnapshotAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
loadError = $"The dashboard could not connect to its database ({ex.GetType().Name}).";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
|
||||
builder.Services.AddScoped<MaterialService>();
|
||||
builder.Services.AddScoped<StockItemService>();
|
||||
builder.Services.AddScoped<JobService>();
|
||||
builder.Services.AddScoped<OverviewService>();
|
||||
builder.Services.AddScoped<CutListPackingService>();
|
||||
builder.Services.AddScoped<ReportService>();
|
||||
builder.Services.AddScoped<CatalogService>();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using CutList.Web.Data;
|
||||
using CutList.Web.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CutList.Web.Services;
|
||||
|
||||
public class OverviewService
|
||||
{
|
||||
private readonly IDbContextFactory<ApplicationDbContext> _factory;
|
||||
|
||||
public OverviewService(IDbContextFactory<ApplicationDbContext> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<OverviewSnapshot> GetSnapshotAsync()
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var recentJobs = await context.Jobs
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(j => new RecentJobOverview(
|
||||
j.Id,
|
||||
j.JobNumber,
|
||||
j.Name,
|
||||
j.Customer,
|
||||
j.CreatedAt,
|
||||
j.LockedAt != null,
|
||||
j.Parts.Sum(p => (int?)p.Quantity) ?? 0))
|
||||
.ToListAsync();
|
||||
|
||||
var frequentStock = await context.JobStocks
|
||||
.AsNoTracking()
|
||||
.GroupBy(s => new { s.MaterialId, s.LengthInches })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.MaterialId,
|
||||
g.Key.LengthInches,
|
||||
JobCount = g.Select(s => s.JobId).Distinct().Count(),
|
||||
Material = g.Select(s => s.Material).First()
|
||||
})
|
||||
.OrderByDescending(s => s.JobCount)
|
||||
.ThenBy(s => s.Material.Shape)
|
||||
.ThenBy(s => s.Material.Size)
|
||||
.ThenBy(s => s.LengthInches)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
var totalJobs = await context.Jobs.CountAsync();
|
||||
var openJobs = await context.Jobs.CountAsync(j => j.LockedAt == null);
|
||||
var totalParts = await context.JobParts.SumAsync(p => (int?)p.Quantity) ?? 0;
|
||||
var activeStockItems = await context.StockItems.CountAsync(s => s.IsActive);
|
||||
|
||||
return new OverviewSnapshot(
|
||||
totalJobs,
|
||||
openJobs,
|
||||
totalParts,
|
||||
activeStockItems,
|
||||
recentJobs,
|
||||
frequentStock.Select(s => new FrequentStockOverview(
|
||||
s.MaterialId,
|
||||
s.Material.DisplayName,
|
||||
s.LengthInches,
|
||||
s.JobCount)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public record OverviewSnapshot(
|
||||
int TotalJobs,
|
||||
int OpenJobs,
|
||||
int TotalParts,
|
||||
int ActiveStockItems,
|
||||
IReadOnlyList<RecentJobOverview> RecentJobs,
|
||||
IReadOnlyList<FrequentStockOverview> FrequentStock);
|
||||
|
||||
public record RecentJobOverview(
|
||||
int Id,
|
||||
string JobNumber,
|
||||
string? Name,
|
||||
string? Customer,
|
||||
DateTime CreatedAt,
|
||||
bool IsLocked,
|
||||
int PartCount);
|
||||
|
||||
public record FrequentStockOverview(
|
||||
int MaterialId,
|
||||
string MaterialName,
|
||||
decimal LengthInches,
|
||||
int JobCount);
|
||||
@@ -272,6 +272,20 @@ a, .btn-link { color: var(--accent-dark); }
|
||||
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
|
||||
.alert { border-radius: .4rem; border-width: 1px; }
|
||||
|
||||
/* Overview */
|
||||
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
|
||||
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
|
||||
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
|
||||
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
|
||||
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
|
||||
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
|
||||
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: Georgia, "Times New Roman", serif; font-size: 2.25rem; letter-spacing: -.06em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
|
||||
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
|
||||
.overview-recent-jobs { min-width: 0; }
|
||||
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.5rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
|
||||
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
|
||||
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
|
||||
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
|
||||
@@ -298,5 +312,5 @@ a, .btn-link { color: var(--accent-dark); }
|
||||
.workflow-list li { align-items: center; border-bottom: 1px solid #c3d2dc; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
|
||||
.workflow-list li > span { align-items: center; background: var(--accent); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
|
||||
.workflow-list strong, .workflow-list small { display: block; }.workflow-list strong { font-size: .9rem; }.workflow-list small { color: var(--muted); font-size: .78rem; margin-top: .1rem; }
|
||||
@media (max-width: 800px) { .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
|
||||
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.workspace-header { height: 3.5rem; padding-left: 4.6rem; }.workspace-context { display: none; } .workspace-status { margin-left: auto; } }
|
||||
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
|
||||
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; }.workspace-header { height: 3.5rem; padding-left: 4.6rem; }.workspace-context { display: none; } .workspace-status { margin-left: auto; } }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Focused regression checks for the data-backed CutList overview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
|
||||
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
|
||||
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
|
||||
|
||||
class OverviewDashboardTests(unittest.TestCase):
|
||||
def test_overview_uses_a_dedicated_data_service(self) -> None:
|
||||
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn("GetSnapshotAsync", source)
|
||||
self.assertIn("context.Jobs", source)
|
||||
self.assertIn("context.JobStocks", source)
|
||||
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
|
||||
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches })", source)
|
||||
|
||||
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("@inject OverviewService OverviewService", markup)
|
||||
self.assertIn("Recently created jobs", markup)
|
||||
self.assertIn("Frequently specified stock", markup)
|
||||
self.assertIn("Based on stock configured on jobs", markup)
|
||||
self.assertIn("overview.RecentJobs", markup)
|
||||
self.assertIn("overview.FrequentStock", markup)
|
||||
|
||||
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
|
||||
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn(".overview-grid", css)
|
||||
self.assertIn(".overview-stock-list", css)
|
||||
self.assertIn(".overview-recent-jobs", css)
|
||||
|
||||
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("loadError", markup)
|
||||
self.assertIn("Overview data is unavailable", markup)
|
||||
self.assertIn("catch (Exception ex)", markup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user