Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Tests

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
python:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
python-version: ['3.10', '3.12']
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: python -m pip install -r requirements.txt
- run: python -m unittest discover -s tests -v

browser:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '24'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
.env
.venv/
venv/
node_modules/
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ python -m http.server 8080
| `POST` | `/weather/trigger-flood` | Simulate flood impact on road assets |
| `POST` | `/assets/{asset_id}/maintenance` | Record a maintenance improvement |

## Tests

Run the Python validation and scoring tests after installing the API dependencies:

```bash
python -m unittest discover -s tests -v
```

For browser regression tests, install Node.js 24 and run:

```bash
npm ci
npx playwright install chromium
npm test
```

The browser tests use mocked API responses and map dependencies; no running API,
database, or external CDN is needed during the tests. They check that asset names
and reports render as literal text, quoted names keep their chart controls,
repairs target the correct asset, and resolved reports leave the incident feed.
Submission tests cover delayed responses, duplicate attempts, successful form
reset, and preserving input for retries after API or network failures.
GitHub Actions runs both suites on pull requests and pushes to `main`.

## Scope

StructIQ is a demonstration prototype, not a production structural-safety
Expand Down
83 changes: 55 additions & 28 deletions auth.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ <h6 class="modal-title text-info fw-bold" id="vibeTitle">TELEMETRY_SCAN</h6>
const assets = await res.json();
document.getElementById('asset-count').innerText = assets.length;
const listDiv = document.getElementById('asset-list');
listDiv.innerHTML = '';
listDiv.replaceChildren();

markers.forEach(m => map.removeLayer(m));
markers = [];
Expand All @@ -222,26 +222,43 @@ <h6 class="modal-title text-info fw-bold" id="vibeTitle">TELEMETRY_SCAN</h6>
if (a.health_score < 40) statusColor = '#ef4444';
else if (a.health_score < 75) statusColor = '#f59e0b';

listDiv.innerHTML += `
<div class="asset-card p-3 rounded mb-2" style="border-left: 6px solid ${statusColor}">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold small text-info" onclick="showGraph('${a.name}')" style="cursor:pointer; text-decoration: underline;">${a.name}</span>
<span class="badge bg-dark border border-secondary" style="font-size:0.6rem;">${a.asset_type}</span>
</div>
<div class="progress mb-2">
<div class="progress-bar" style="width: ${a.health_score}%; background-color: ${statusColor}"></div>
</div>
<div class="d-flex justify-content-between opacity-75 mb-2" style="font-size:0.7rem; font-family: var(--font-mono);">
<span>HEALTH: ${a.health_score.toFixed(1)}%</span>
<span>ID: #${a.id}</span>
</div>
<button onclick="repairAsset(${a.id})" class="btn btn-sm btn-outline-success w-100 py-0" style="font-size: 10px;">INITIATE REPAIR</button>
</div>`;

const card = document.createElement('div');
card.className = 'asset-card p-3 rounded mb-2';
card.style.borderLeft = `6px solid ${statusColor}`;
// Only static markup belongs in this template. API values use textContent.
card.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-1">
<button type="button" class="asset-name btn btn-link p-0 text-start fw-bold small text-info"></button>
<span class="asset-type badge bg-dark border border-secondary" style="font-size:0.6rem;"></span>
</div>
<div class="progress mb-2">
<div class="progress-bar"></div>
</div>
<div class="d-flex justify-content-between opacity-75 mb-2" style="font-size:0.7rem; font-family: var(--font-mono);">
<span class="asset-health"></span>
<span class="asset-id"></span>
</div>
<button type="button" class="repair-asset btn btn-sm btn-outline-success w-100 py-0" style="font-size: 10px;">INITIATE REPAIR</button>`;
const nameButton = card.querySelector('.asset-name');
nameButton.textContent = a.name;
nameButton.addEventListener('click', () => showGraph(a.name));
card.querySelector('.asset-type').textContent = a.asset_type;
card.querySelector('.asset-health').textContent = `HEALTH: ${a.health_score.toFixed(1)}%`;
card.querySelector('.asset-id').textContent = `ID: #${a.id}`;
const progressBar = card.querySelector('.progress-bar');
progressBar.style.width = `${a.health_score}%`;
progressBar.style.backgroundColor = statusColor;
card.querySelector('.repair-asset').addEventListener('click', () => repairAsset(a.id));
listDiv.append(card);

const popup = document.createElement('div');
const popupName = document.createElement('b');
popupName.textContent = a.name;
popup.append(popupName, document.createElement('br'), `Health: ${a.health_score.toFixed(1)}%`);
const m = L.circleMarker([a.latitude, a.longitude], {
radius: 10, color: 'white', weight: 2, fillColor: statusColor, fillOpacity: 0.9
}).addTo(map).bindPopup(`<b>${a.name}</b><br>Health: ${a.health_score.toFixed(1)}%`);
}).addTo(map).bindPopup(popup);

if (a.health_score < 40) { m.getElement()?.classList.add('emergency-mode'); }
markers.push(m);
});
Expand All @@ -258,15 +275,25 @@ <h6 class="modal-title text-info fw-bold" id="vibeTitle">TELEMETRY_SCAN</h6>
const res = await fetch(`${API_URL}/reports`);
const reports = await res.json();
const feed = document.getElementById('incident-feed');
if (reports.length > 0) {
feed.innerHTML = '';
reports.reverse().slice(0, 8).forEach(rep => {
feed.innerHTML += `
<div class="ticket-log">
[${new Date().toLocaleTimeString()}] REPORT #${rep.id}<br>
> ASSET: ${rep.asset_id} | SEV: ${rep.severity}<br>
> ${rep.description}<br>
</div>`;
feed.replaceChildren();
if (reports.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'text-muted';
emptyState.textContent = 'No open reports.';
feed.append(emptyState);
} else {
reports.slice(-8).reverse().forEach(rep => {
const entry = document.createElement('div');
entry.className = 'ticket-log';
entry.append(
`[${new Date().toLocaleTimeString()}] REPORT #${rep.id}`,
document.createElement('br'),
`> ASSET: ${rep.asset_id} | SEV: ${rep.severity}`,
document.createElement('br'),
`> ${rep.description}`,
document.createElement('br'),
);
feed.append(entry);
});
}
} catch(e) { }
Expand Down
104 changes: 47 additions & 57 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,8 @@ <h3 style="margin: 0; color: var(--status-critical);">⚠ NEW INCIDENT REPORT</h
<textarea class="form-textarea" id="description" rows="3" placeholder="Describe visible damage patterns for AI verification..." required></textarea>
</div>

<p id="reportStatus" role="status" aria-live="polite" hidden></p>

<button type="submit" class="btn btn-primary" style="width: 100%; background: var(--status-critical); border-color: var(--status-critical);">SUBMIT FOR AI ANALYSIS</button>
</form>
</div>
Expand Down Expand Up @@ -1145,39 +1147,6 @@ <h3 style="margin: 0; color: var(--status-critical);">⚠ NEW INCIDENT REPORT</h
});
}

// Report Form Submit
reportForm.addEventListener('submit', (e) => {
e.preventDefault();
const btn = reportForm.querySelector('button[type="submit"]');
const originalText = btn.innerHTML;

btn.innerHTML = 'UPLOADING DATA PACKET...';
btn.style.opacity = '0.7';
btn.disabled = true;

setTimeout(() => {
btn.innerHTML = '✔ REPORT LOGGED';
btn.style.background = 'var(--status-safe)';
btn.style.borderColor = 'var(--status-safe)';

setTimeout(() => {
toggleModalById('reportModal', false);
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.background = 'var(--status-critical)';
btn.style.borderColor = 'var(--status-critical)';
btn.style.opacity = '1';
btn.disabled = false;
reportForm.reset();
uploadText.textContent = "Drop Photo/Video or Click to Browse";
uploadText.classList.remove('file-selected');
dropZone.style.borderColor = '';
dropZone.style.background = '';
}, 300);
}, 1500);
}, 1500);
});

// --- AUTH MODAL ---
const authBtn = document.getElementById('authBtn');
const loginModalClose = document.querySelector('#loginModal .close-modal');
Expand Down Expand Up @@ -1300,51 +1269,72 @@ <h3 style="margin: 0; color: var(--status-critical);">⚠ NEW INCIDENT REPORT</h
const assets = await res.json();
const select = document.getElementById('assetSelect');

select.innerHTML = '<option value="">Select Bridge or Road...</option>';
select.replaceChildren(new Option('Select Bridge or Road...', ''));
assets.forEach(asset => {
// Citizen sees name, backend gets ID
select.innerHTML += `<option value="${asset.id}">${asset.name}</option>`;
select.add(new Option(asset.name, String(asset.id)));
});
} catch (e) {
console.error("Failed to load assets", e);
}
}

// --- CONNECT: Handle the Submit Button ---
document.getElementById('reportForm').onsubmit = async (e) => {
// Keep submission feedback tied to the API response.
let reportSubmissionPending = false;
reportForm.addEventListener('submit', async (e) => {
e.preventDefault();

const assetId = document.getElementById('assetSelect').value;
const file = document.getElementById('fileInput').files[0];
const description = document.querySelector('.form-textarea').value;

if(!assetId || !file) {
alert("Please select a location and upload an image.");
return;
}
if (reportSubmissionPending || !reportForm.reportValidity()) return;

const btn = reportForm.querySelector('button[type="submit"]');
const status = document.getElementById('reportStatus');
const originalText = btn.textContent;
const formData = new FormData();
formData.append('asset_id', assetId);
formData.append('description', description);
formData.append('file', file);
formData.append('asset_id', document.getElementById('assetSelect').value);
formData.append('description', document.getElementById('description').value);
formData.append('file', fileInput.files[0]);

reportSubmissionPending = true;
reportForm.setAttribute('aria-busy', 'true');
btn.disabled = true;
btn.textContent = 'SUBMITTING REPORT...';
status.hidden = false;
status.textContent = 'Submitting your report. Please wait for confirmation.';

try {
const res = await fetch(`${API_URL}/reports/upload-ai`, {
method: 'POST',
body: formData
});
const data = await res.json().catch(() => ({}));

if(res.ok) {
const data = await res.json();
alert(`Report Accepted! AI Severity: ${data.analysis}`);
document.getElementById('reportModal').style.display = 'none';
} else {
alert("Submission failed. Check backend connection.");
if (!res.ok) {
status.textContent = typeof data?.detail === 'string'
? `Submission failed: ${data.detail}`
: 'Submission failed. Please try again.';
return;
}

alert(typeof data?.analysis === 'string'
? `Report accepted! AI severity: ${data.analysis}`
: 'Report accepted! Your report was saved.');
reportForm.reset();
uploadText.textContent = 'Drop Photo or Click to Browse';
uploadText.classList.remove('file-selected');
dropZone.style.borderColor = '';
dropZone.style.background = '';
status.textContent = '';
status.hidden = true;
toggleModalById('reportModal', false);
} catch (err) {
console.error(err);
console.error('Report submission failed', err);
status.textContent = 'Unable to submit your report. Check your connection and try again.';
} finally {
reportSubmissionPending = false;
reportForm.removeAttribute('aria-busy');
btn.disabled = false;
btn.textContent = originalText;
}
};
});

// Initialize the dropdown when page loads
window.addEventListener('DOMContentLoaded', loadAssetsForCitizen);
Expand Down
60 changes: 60 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "structiq-browser-tests",
"private": true,
"scripts": {
"test": "node --test tests/test_rendering.cjs"
},
"devDependencies": {
"playwright": "1.62.1"
}
}
Loading
Loading