recognition: recognition.js showBanner/handleSaveResponse; wired into sky SAVE handler on applet & sky page — TDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
49
src/apps/dashboard/static/apps/dashboard/recognition.js
Normal file
49
src/apps/dashboard/static/apps/dashboard/recognition.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const Recognition = (() => {
|
||||
'use strict';
|
||||
|
||||
function showBanner(recognition) {
|
||||
if (!recognition) return;
|
||||
|
||||
const earned = new Date(recognition.earned_at);
|
||||
const dateStr = earned.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'recog-banner';
|
||||
banner.innerHTML =
|
||||
'<div class="recog-banner__body">' +
|
||||
'<p class="recog-banner__title">' + _esc(recognition.title) + '</p>' +
|
||||
'<p class="recog-banner__description">' + _esc(recognition.description) + '</p>' +
|
||||
'<time class="recog-banner__timestamp" datetime="' + _esc(recognition.earned_at) + '">' +
|
||||
dateStr +
|
||||
'</time>' +
|
||||
'</div>' +
|
||||
'<div class="recog-banner__image"></div>' +
|
||||
'<button type="button" class="btn btn-danger recog-banner__nvm">NVM</button>' +
|
||||
'<a href="/billboard/recognition/" class="btn btn-caution recog-banner__fyi">FYI</a>';
|
||||
|
||||
banner.querySelector('.recog-banner__nvm').addEventListener('click', function () {
|
||||
banner.remove();
|
||||
});
|
||||
|
||||
var h2 = document.querySelector('h2');
|
||||
if (h2 && h2.parentNode) {
|
||||
h2.parentNode.insertBefore(banner, h2.nextSibling);
|
||||
} else {
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveResponse(data) {
|
||||
showBanner(data && data.recognition);
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = str || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
|
||||
})();
|
||||
143
src/static/tests/RecognitionSpec.js
Normal file
143
src/static/tests/RecognitionSpec.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ── RecognitionSpec.js ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for recognition.js — banner injection from sky/save response.
|
||||
//
|
||||
// DOM contract assumed by showBanner():
|
||||
// Any <h2> in the document — banner is inserted immediately after it.
|
||||
// If absent, banner is prepended to <body>.
|
||||
//
|
||||
// API under test:
|
||||
// Recognition.showBanner(recognition)
|
||||
// recognition = { slug, title, description, earned_at } → inject .recog-banner
|
||||
// recognition = null → no-op
|
||||
//
|
||||
// Recognition.handleSaveResponse(data)
|
||||
// data = { saved: true, recognition: {...} } → delegates to showBanner
|
||||
// data = { saved: true, recognition: null } → no-op
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RECOGNITION = {
|
||||
slug: 'stargazer',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
};
|
||||
|
||||
describe('Recognition.showBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = document.createElement('div');
|
||||
fixture.id = 'recognition-fixture';
|
||||
fixture.innerHTML = '<h2>Dash</h2>';
|
||||
document.body.appendChild(fixture);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
|
||||
fixture.remove();
|
||||
});
|
||||
|
||||
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
||||
|
||||
it('T1: showBanner(null) does not inject a banner', () => {
|
||||
Recognition.showBanner(null);
|
||||
expect(document.querySelector('.recog-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T2 ── recognition present → banner in DOM ─────────────────────────────
|
||||
|
||||
it('T2: showBanner(recognition) injects .recog-banner into the document', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T3 ── title ───────────────────────────────────────────────────────────
|
||||
|
||||
it('T3: banner .recog-banner__title contains recognition.title', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const el = document.querySelector('.recog-banner__title');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('Stargazer');
|
||||
});
|
||||
|
||||
// ── T4 ── description ─────────────────────────────────────────────────────
|
||||
|
||||
it('T4: banner .recog-banner__description contains recognition.description', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const el = document.querySelector('.recog-banner__description');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('You saved your first personal sky chart.');
|
||||
});
|
||||
|
||||
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
||||
|
||||
it('T5: banner has a .recog-banner__timestamp element', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner__timestamp')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T6 ── image area ──────────────────────────────────────────────────────
|
||||
|
||||
it('T6: banner has a .recog-banner__image element', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner__image')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T7 ── NVM button ──────────────────────────────────────────────────────
|
||||
|
||||
it('T7: banner has a .btn.btn-danger NVM button', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner .btn.btn-danger')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T8 ── FYI link ────────────────────────────────────────────────────────
|
||||
|
||||
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/recognition/', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const fyi = document.querySelector('.recog-banner .btn.btn-caution');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/recognition/');
|
||||
});
|
||||
|
||||
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
||||
|
||||
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
document.querySelector('.recog-banner .btn.btn-danger').click();
|
||||
expect(document.querySelector('.recog-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T10 ── placement after h2 ─────────────────────────────────────────────
|
||||
|
||||
it('T10: banner is inserted immediately after the first h2 in the document', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const h2 = fixture.querySelector('h2');
|
||||
expect(h2.nextElementSibling.classList.contains('recog-banner')).toBeTrue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Recognition.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
|
||||
});
|
||||
|
||||
// ── T11 ── delegates when recognition present ─────────────────────────────
|
||||
|
||||
it('T11: handleSaveResponse shows banner when data.recognition is present', () => {
|
||||
Recognition.handleSaveResponse({ saved: true, recognition: SAMPLE_RECOGNITION });
|
||||
expect(document.querySelector('.recog-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T12 ── no banner when recognition null ────────────────────────────────
|
||||
|
||||
it('T12: handleSaveResponse does not show banner when data.recognition is null', () => {
|
||||
Recognition.handleSaveResponse({ saved: true, recognition: null });
|
||||
expect(document.querySelector('.recog-banner')).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -23,8 +23,10 @@
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<script src="NatusWheelSpec.js"></script>
|
||||
<script src="RecognitionSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/dashboard/recognition.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
|
||||
143
src/static_src/tests/RecognitionSpec.js
Normal file
143
src/static_src/tests/RecognitionSpec.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ── RecognitionSpec.js ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for recognition.js — banner injection from sky/save response.
|
||||
//
|
||||
// DOM contract assumed by showBanner():
|
||||
// Any <h2> in the document — banner is inserted immediately after it.
|
||||
// If absent, banner is prepended to <body>.
|
||||
//
|
||||
// API under test:
|
||||
// Recognition.showBanner(recognition)
|
||||
// recognition = { slug, title, description, earned_at } → inject .recog-banner
|
||||
// recognition = null → no-op
|
||||
//
|
||||
// Recognition.handleSaveResponse(data)
|
||||
// data = { saved: true, recognition: {...} } → delegates to showBanner
|
||||
// data = { saved: true, recognition: null } → no-op
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RECOGNITION = {
|
||||
slug: 'stargazer',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
};
|
||||
|
||||
describe('Recognition.showBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = document.createElement('div');
|
||||
fixture.id = 'recognition-fixture';
|
||||
fixture.innerHTML = '<h2>Dash</h2>';
|
||||
document.body.appendChild(fixture);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
|
||||
fixture.remove();
|
||||
});
|
||||
|
||||
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
||||
|
||||
it('T1: showBanner(null) does not inject a banner', () => {
|
||||
Recognition.showBanner(null);
|
||||
expect(document.querySelector('.recog-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T2 ── recognition present → banner in DOM ─────────────────────────────
|
||||
|
||||
it('T2: showBanner(recognition) injects .recog-banner into the document', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T3 ── title ───────────────────────────────────────────────────────────
|
||||
|
||||
it('T3: banner .recog-banner__title contains recognition.title', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const el = document.querySelector('.recog-banner__title');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('Stargazer');
|
||||
});
|
||||
|
||||
// ── T4 ── description ─────────────────────────────────────────────────────
|
||||
|
||||
it('T4: banner .recog-banner__description contains recognition.description', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const el = document.querySelector('.recog-banner__description');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('You saved your first personal sky chart.');
|
||||
});
|
||||
|
||||
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
||||
|
||||
it('T5: banner has a .recog-banner__timestamp element', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner__timestamp')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T6 ── image area ──────────────────────────────────────────────────────
|
||||
|
||||
it('T6: banner has a .recog-banner__image element', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner__image')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T7 ── NVM button ──────────────────────────────────────────────────────
|
||||
|
||||
it('T7: banner has a .btn.btn-danger NVM button', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
expect(document.querySelector('.recog-banner .btn.btn-danger')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T8 ── FYI link ────────────────────────────────────────────────────────
|
||||
|
||||
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/recognition/', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const fyi = document.querySelector('.recog-banner .btn.btn-caution');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/recognition/');
|
||||
});
|
||||
|
||||
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
||||
|
||||
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
document.querySelector('.recog-banner .btn.btn-danger').click();
|
||||
expect(document.querySelector('.recog-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T10 ── placement after h2 ─────────────────────────────────────────────
|
||||
|
||||
it('T10: banner is inserted immediately after the first h2 in the document', () => {
|
||||
Recognition.showBanner(SAMPLE_RECOGNITION);
|
||||
const h2 = fixture.querySelector('h2');
|
||||
expect(h2.nextElementSibling.classList.contains('recog-banner')).toBeTrue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Recognition.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
|
||||
});
|
||||
|
||||
// ── T11 ── delegates when recognition present ─────────────────────────────
|
||||
|
||||
it('T11: handleSaveResponse shows banner when data.recognition is present', () => {
|
||||
Recognition.handleSaveResponse({ saved: true, recognition: SAMPLE_RECOGNITION });
|
||||
expect(document.querySelector('.recog-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T12 ── no banner when recognition null ────────────────────────────────
|
||||
|
||||
it('T12: handleSaveResponse does not show banner when data.recognition is null', () => {
|
||||
Recognition.handleSaveResponse({ saved: true, recognition: null });
|
||||
expect(document.querySelector('.recog-banner')).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -23,8 +23,10 @@
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<script src="NatusWheelSpec.js"></script>
|
||||
<script src="RecognitionSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/dashboard/recognition.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load static %}
|
||||
<script src="{% static 'apps/dashboard/recognition.js' %}"></script>
|
||||
<section
|
||||
id="id_applet_my_sky"
|
||||
data-preview-url="{% url 'sky_preview' %}"
|
||||
@@ -327,10 +328,11 @@
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
.then(data => {
|
||||
formWrap.style.display = 'none';
|
||||
svgEl.style.display = '';
|
||||
NatusWheel.preload().then(() => NatusWheel.draw(svgEl, _lastChartData));
|
||||
Recognition.handleSaveResponse(data);
|
||||
})
|
||||
.catch(err => {
|
||||
setStatus(`Save failed: ${err.message}`, 'error');
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
|
||||
<script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script>
|
||||
<script src="{% static 'apps/dashboard/recognition.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -299,7 +300,7 @@
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(() => setStatus('Sky saved!'))
|
||||
.then(data => { setStatus('Sky saved!'); Recognition.handleSaveResponse(data); })
|
||||
.catch(err => {
|
||||
setStatus(`Save failed: ${err.message}`, 'error');
|
||||
confirmBtn.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user