diff --git a/docs/superpowers/specs/2026-07-07-radon-osiagniecia-autora-design.md b/docs/superpowers/specs/2026-07-07-radon-osiagniecia-autora-design.md new file mode 100644 index 000000000..801e6cd88 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-radon-osiagniecia-autora-design.md @@ -0,0 +1,224 @@ +# Osiągnięcia autora z RAD-on OpenData (client-side, po ORCID) + +**Data:** 2026-07-07 +**Gałąź:** `feat/radon-profil-autora` (na bazie `feature/profil-autora`, PR #385) +**Status:** spec zaakceptowany do implementacji + +## Cel + +Na podstronie autora (`/autor//`) dla naukowców posiadających ORCID +dodać sekcję z ich osiągnięciami pobranymi **na żywo z RAD-on OpenData** — +**po stronie klienta** (fetch z przeglądarki), z podpisem drobnymi literami +„informacje pobrane z RAD-on". + +Pokazujemy to, czego BPP nie ma we własnej bazie: **stopnie/tytuły naukowe, +zatrudnienie, projekty naukowe (z kwotami), patenty i prawa ochronne, +osiągnięcia artystyczne (z nagrodami)**. **Publikacji NIE pobieramy** — BPP ma +je we własnej bazie. + +Kod odpytujący RAD-on ma być **wydzielonym, przenośnym modułem JS** (bez +zależności od BPP), tak by dało się go wyjąć i użyć gdzie indziej. + +## Ustalenia badawcze (przetestowane na żywo 2026-07-07) + +Reverse-engineering + testy żywego API. **Cała rodzina `/opendata/*` ma CORS +otwarty** (preflight/GET/POST odbijają `Origin` → fetch z przeglądarki działa; +zweryfikowane na `scientist/search`, `polon/projects`, `polon/products`, +`polon/artisticAchievements`, `polon/publications`). + +Wszystkie endpointy: baza `https://radon.nauka.gov.pl/opendata`, odpowiedź +`{results[], pagination{maxCount, token}, version}`, paginacja kursorem +`token` (`null`/pominięty na starcie). + +### A. `scientist` (dane zintegrowane) — CV: stopnie/tytuły/zatrudnienie + +- **`POST /opendata/scientist/search`**, ciało: + ```json + {"resultNumbers": 10, "token": null, "body": {"firstName": "...", "lastName": "..."}} + ``` + `token` MUSI być `null`/pominięty na starcie (`""` → `400 Malformed token`). + Kryteria (`body`): `firstName`, `lastName`, `uid`, markery. **Bez filtra + `orcid`.** +- Rekord: `personalData{orcid, id(uid POL-on 40-hex), firstName, middleName, + lastName}`, `academicDegrees[]` (Doktor/Doktor habilitowany + `grantingYear` + + `grantingInstitutionName` + `degreeClassification`), `academicTitles[]` + (profesura + rok), `professionalTitles[]` (magisterium + rok + uczelnia), + `employments[]`, `calculatedEduLevel`, `dataSources`. +- **Wynik zawiera `personalData.orcid`** → dopasowanie po nazwisku, weryfikacja + po ORCID. + +### B. `polon/projects` — PROJEKTY NAUKOWE (filtr po kierowniku) + +- **`GET /opendata/polon/projects`** z parametrami: + `projectManagerFirstName`, `projectManagerLastName`, `disciplineName`, + `disciplineCode`, `entityShowingAchievementsName`, `projectStartDate`, + `projectEndDate`, `resultNumbers`, `token`. +- Rekord: `projectTitlePl`/`projectTitleEn`, `acronym`, `totalFunds`, + `receivedFunds`, `nationalFunds`, `foreignFunds`, `projectStartDate`, + `projectEndDate`, `projectGrantDate`, `projectClassification`, + `financedCompetition`, `financingInstitutions[]`, `implementingInstitutions[]`, + `disciplines[]`, `dataSource`, oraz **`projectManagers[]`** z polami + `firstName`, `middleName`, `lastName`, **`ORCID`**, `kindManager`, + `institutionName`, `startDate`, `endDate`. +- **Weryfikacja:** `projectManagers[].ORCID == autor.orcid` (potwierdzone na + żywo: Kowalczewski → 3 projekty, manager ORCID = `0000-0002-0153-4624`, + kwoty w `totalFunds`). + +### C. `polon/products` — PATENTY I PRAWA OCHRONNE (filtr po wynalazcy) + +- **`GET /opendata/polon/products`**: + `inventorFirstName`, `inventorLastName`, `institutionName`, + `productTitle`, `protectionTypeCode`, `publicationDateFrom/To`, + `resultNumbers`, `token`. +- Rekord: `productTitles[]`, `protectionType`, `protectionTitle`, + `publicationNumber`, `publicationDate`, `applicationDate`, + `grantingInstitutionName`, `productDescription`, `applicants[]`, oraz + **`inventors[]`** → `persons[]` z `firstName`, `lastName`, + **`relatedOrcid`** (bywa `null`). +- **Weryfikacja:** `inventors[].persons[].relatedOrcid == autor.orcid`; gdy + `relatedOrcid` puste — dopasowanie tylko po nazwisku (słabsze; oznaczamy jako + „niezweryfikowane po ORCID" wewnętrznie, patrz reguła niżej). + +### D. `polon/artisticAchievements` — OSIĄGNIĘCIA ARTYSTYCZNE (filtr po autorze) + +- **`GET /opendata/polon/artisticAchievements`**: + `authorFirstName`, `authorLastName`, `institutionName`, `title`, + `achievementKindCode`, `implementationYearFrom/To`, `resultNumbers`, `token`. +- Rekord: `title`, `discipline`, `achievementKind`, `achievementType`, + `implementationYear`, `firstPublicationYear`, `publisherName`, + `achievementRange`, **`awards[]`** (`competitionName`, `awardYear`, + `awardingInstitution`), oraz **`authors[]`** (`AuthorData`) z `firstName`, + `lastName`, **`orcid`**. +- **Weryfikacja:** `authors[].orcid == autor.orcid`. (Domena akademii sztuk — + dla większości autorów `maxCount=0`; sekcja typu chowa się pusta.) + +### Czego świadomie NIE używamy + +- **Publikacje** (`polon/publications`, filtr `orcidId` istnieje) — **BPP ma + publikacje we własnej bazie**. Poza zakresem. +- **`polon/employees`** — brak ORCID, odrzucone na rzecz `scientist`. +- **Ludzie Nauki** (`ludzie.nauka.gov.pl`) — CORS zamknięty, OAuth, + `profileId` nie z ORCID. Poza zakresem. +- **Usługi zintegrowane `project`/`publication`/`product`** — kryteria bez + osoby; używamy per-osobowych usług POL-on (B/C/D powyżej). + +## Zakres + +### Wchodzi + +1. **Przenośny moduł JS „odpytywacz RAD-on"** (`radon-client`), bez zależności + od BPP, natywny `fetch`, konfigurowalny `baseUrl` (domyślnie + `https://radon.nauka.gov.pl/opendata`), z metodami: + - `searchScientist({firstName, lastName})` → `Scientist[]` (POST + `scientist/search`). + - `fetchProjects({firstName, lastName})` → `Project[]` + (GET `polon/projects`). + - `fetchPatents({firstName, lastName})` → `Patent[]` + (GET `polon/products`). + - `fetchArtisticAchievements({firstName, lastName})` → `Achievement[]` + (GET `polon/artisticAchievements`). + - Helper `orcidMatches(a, b)` — normalizuje (usuwa URL/spacje/myślniki, + case-insensitive) i porównuje. + - Selektory: `pickScientistByOrcid`, `filterProjectsByOrcid` (po + `projectManagers[].ORCID`), `filterPatentsByOrcid` (po + `inventors[].persons[].relatedOrcid`), `filterAchievementsByOrcid` (po + `authors[].orcid`). + - Normalizatory `extract*` → płaskie obiekty do renderu (patrz „Pola + wyświetlane"). + - Zero zależności zewnętrznych; sprawdzone na NPM — gotowego klienta + RAD-on/POL-on nie ma (repo OPI to notebooki R/Python). +2. **Warstwa integracji BPP** (cienki, osobny plik JS) — czyta `data-*` + (imię, nazwisko, orcid) z kontenera sekcji, woła powyższe metody + równolegle (`Promise.allSettled`), filtruje po ORCID, renderuje pod-bloki, + dokleja podpis „informacje pobrane z RAD-on". +3. **Sekcja w rejestrze profilu** (`bpp.profil_autora`): nowy klucz + `KLUCZ_RADON = "radon_osiagniecia"`, `TypSekcji(..., template_only=True)`, + renderowana tylko gdy `autor.orcid`. Kolumna domyślna: LEWA. +4. **Partial** `autor_sekcje/radon_osiagniecia.html`: kontener z + `data-orcid`/`data-imie`/`data-nazwisko`, stan „ładowanie…", cztery + pod-kontenery (CV / projekty / patenty / osiągnięcia artystyczne), podpis. +5. **Degradacja bez błędów, per pod-blok:** każdy typ danych ładuje się + niezależnie; brak sieci / 0 wyników / brak ORCID-matcha → dany pod-blok się + chowa; gdy wszystkie puste → cała sekcja się chowa. + +### Pola wyświetlane (per typ) + +- **CV (scientist):** stopnie „Doktor 2016, UP Poznań", „Doktor habilitowany + 2023", tytuł profesorski (rok), zatrudnienie (instytucja + od), dyscypliny. +- **Projekty:** tytuł PL, program/konkurs (`financedCompetition`), + kwota (`totalFunds`), lata (`projectStartDate`–`projectEndDate`), + instytucja finansująca, rola (`projectManagers[].kindManager`). +- **Patenty:** tytuł (`productTitles`), typ ochrony (`protectionType`), + nr i data publikacji, instytucja udzielająca. +- **Osiągnięcia artystyczne:** tytuł, rodzaj, rok, nagrody (`awards[]`: + konkurs + rok). + +### Świadomie poza zakresem + +Publikacje; Ludzie Nauki; proxy/OAuth; pola/migracje na modelu; cache po +stronie serwera; rozstrzyganie homonimów inne niż po ORCID. + +## Model danych + +**Brak zmian modelu, brak migracji.** Używamy `Autor.orcid` + imię/nazwisko. +Nic nie zapisujemy — dane ulotne, pobierane na żywo w przeglądarce. + +## Reguła dopasowania po ORCID + +1. Normalizacja ORCID po obu stronach do 16 znaków (cyfry + `X`), porównanie + case-insensitive. +2. **scientist / projekty / osiągnięcia artystyczne:** pokazujemy wyłącznie + rekordy ze zgodnym ORCID (`personalData.orcid` / `projectManagers[].ORCID` + / `authors[].orcid`). Brak zgodnego → pod-blok pusty → schowany. +3. **patenty:** rekord ze zgodnym `relatedOrcid` pokazujemy zawsze; rekord z + `relatedOrcid == null` (dane POL-on niekompletne) pokazujemy tylko gdy + nazwisko+imię są jednoznaczne dla tego autora — inaczej pomijamy. (Patenty + bez ORCID to jedyny słabszy przypadek; świadomie ostrożni.) + +## Bezpieczeństwo / prywatność + +- Wywołania do publicznego, rządowego API RAD-on (dane jawne POL-on/PBN). +- ORCID + imię/nazwisko są już publiczne na podstronie — brak nowego wycieku. +- Brak sekretów/tokenów w JS. Brak `credentials` w `fetch`. +- Sekcja tylko gdy `autor.orcid` — nie odpytujemy RAD-on bez potrzeby. +- Render przez `textContent`/tworzenie węzłów, **nie** `innerHTML` z surowych + pól API → zero XSS z odpowiedzi RAD-on. + +## Obsługa błędów (żadnych cichych połknięć) + +- Każde zapytanie w `Promise.allSettled`; odrzucone/`!ok`/timeout → + `console.debug("[radon] ", err)` + schowanie danego pod-bloku. + Nie `throw` w górę (nie wywalamy strony), ale zawsze logujemy powód. +- Nieoczekiwany kształt odpowiedzi → traktowany jak brak wyniku (log + chowaj). + +## Testy + +**JS:** +- Każda metoda `fetch*`: buduje poprawny URL/ciało, parsuje `results`, + obsługuje pustkę i błąd sieci (mock `fetch`). +- `orcidMatches`: różne formaty (URL, myślniki, spacje), case, `null`. +- Selektory `filter*ByOrcid`: rozróżniają dwie osoby o tym samym nazwisku po + ORCID; projekty po `projectManagers[].ORCID`; patenty po + `inventors[].persons[].relatedOrcid`; osiągnięcia po `authors[].orcid`. +- `extract*`: mapują pola do renderu; odporne na brakujące/`null`. +- Degradacja: część zapytań odrzucona → pozostałe pod-bloki renderują, + odrzucone chowają się, brak wyjątku globalnego. + +**Python (pytest):** +- Render sekcji: `autor.orcid` ustawiony → kontener z poprawnymi `data-*`; + pusty ORCID → brak sekcji. +- Sekcja w `KATALOG_SEKCJI`, przechodzi `waliduj_uklad`/`rozwiaz_uklad` + (forward-compat: dokleja się bez migracji danych). + +**Bez E2E odpytującego żywy RAD-on** (flaky/sieć zewnętrzna). Kontrakt API +udokumentowany w tym specu; logika na mockach. + +## Ryzyka + +- RAD-on zmieni API/kryteria/CORS → dany pod-blok degraduje do „schowany". + Kontrakt spisany tu. +- Autor bez rekordów w RAD-on → pod-bloki puste → schowane. Oczekiwane. +- Rozjazd imię/nazwisko BPP vs POL-on → brak dopasowania. Akceptowalne; + ORCID-match jest twardym warunkiem poprawności (poza opisanym wyjątkiem + patentów bez ORCID). +- Patenty bez `relatedOrcid` → ostrożna reguła nazwiskowa; ryzyko rzadkie. diff --git a/package.json b/package.json index 841a23f35..ee2368421 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "3d-force-graph": "^1.80.0", + "@iplweb/radon-opendata": "^0.1.1", "basic-ftp": "5.3.1", "cytoscape": "^3.34.0", "cytoscape-fcose": "^2.2.0", diff --git a/src/bpp/profil_autora.py b/src/bpp/profil_autora.py index 5533a5d5d..450085839 100644 --- a/src/bpp/profil_autora.py +++ b/src/bpp/profil_autora.py @@ -44,6 +44,7 @@ KLUCZ_IDENTYFIKATORY = "identyfikatory" KLUCZ_METRYKI = "metryki" KLUCZ_STOPNIE = "stopnie" +KLUCZ_RADON = "radon_osiagniecia" KLUCZ_POPRZEDNIE_NAZWISKA = "poprzednie_nazwiska" KLUCZ_OPIS = "opis" KLUCZ_CYTOWANIA = "cytowania" @@ -116,6 +117,12 @@ def template(self): TypSekcji(KLUCZ_IDENTYFIKATORY, "Identyfikatory", KOLUMNA_LEWA, template_only=True), TypSekcji(KLUCZ_METRYKI, "Metryki ewaluacyjne", KOLUMNA_LEWA, template_only=True), TypSekcji(KLUCZ_STOPNIE, "Stopnie naukowe", KOLUMNA_LEWA, template_only=True), + TypSekcji( + KLUCZ_RADON, + "Osiągnięcia (RAD-on)", + KOLUMNA_LEWA, + template_only=True, + ), TypSekcji( KLUCZ_POPRZEDNIE_NAZWISKA, "Poprzednie nazwiska", diff --git a/src/bpp/static/bpp/js/bundle-entry.js b/src/bpp/static/bpp/js/bundle-entry.js index faa497420..3888205ca 100644 --- a/src/bpp/static/bpp/js/bundle-entry.js +++ b/src/bpp/static/bpp/js/bundle-entry.js @@ -62,6 +62,8 @@ import './jsi18n-pl.js'; // ===== 10. BPP APPLICATION CODE ===== import './bpp.js'; import './form-handlers.js'; +// Sekcja „Osiągnięcia (RAD-on)" na podstronie autora — auto-wire przy załadowaniu. +import './radon-profil.js'; import '../../../../../.venv/lib/python/site-packages/channels_broadcast/static/channels_broadcast/js/notifications.js'; // ===== 11. GLOBAL EXPORTS ===== diff --git a/src/bpp/static/bpp/js/radon-profil.js b/src/bpp/static/bpp/js/radon-profil.js new file mode 100644 index 000000000..49fd65b03 --- /dev/null +++ b/src/bpp/static/bpp/js/radon-profil.js @@ -0,0 +1,150 @@ +// Sekcja „Osiągnięcia (RAD-on)" na podstronie autora — warstwa integracji. +// +// Pobiera dane po stronie klienta z RAD-on OpenData (pakiet @iplweb/radon-opendata), +// dopasowując rekordy po ORCID autora, i renderuje je do kontenera +// [data-radon-osiagniecia]. Cała treść trafia przez textContent (bez innerHTML) +// — żadne pole z API nie może wstrzyknąć HTML-a. Przy braku danych/błędzie +// kontener zostaje ukryty (degradacja bez wyjątku). +import { + createRadonClient, + fetchAchievementsByOrcid, +} from "@iplweb/radon-opendata"; + +function el(tag, text, className) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null && text !== "") node.textContent = String(text); + return node; +} + +function formatPln(n) { + if (n == null) return null; + const grouped = String(n).replace(/\B(?=(\d{3})+(?!\d))/g, " "); + return grouped + " zł"; +} + +function lata(od, doo) { + const a = (od || "").slice(0, 4); + const b = (doo || "").slice(0, 4); + if (a && b) return `${a}–${b}`; + return a || b || ""; +} + +function blok(tytul, pozycje) { + if (!pozycje.length) return null; + const wrap = el("div", null, "radon-osiagniecia__blok"); + wrap.appendChild(el("h4", tytul, "radon-osiagniecia__blok-tytul")); + const ul = el("ul", null, "radon-osiagniecia__lista"); + for (const linia of pozycje) ul.appendChild(el("li", linia)); + wrap.appendChild(ul); + return wrap; +} + +/** + * Wyrenderuj wynik RAD-on do kontenera i pokaż go. Pusty wynik → kontener + * zostaje bez zmian (ukryty). Wszystko przez textContent (anty-XSS). + */ +export function renderRadonAchievements(container, result) { + const wynik = container.querySelector(".radon-osiagniecia__wynik"); + if (!wynik || !result) return; + wynik.textContent = ""; + + const bloki = []; + + if (result.cv) { + const cv = result.cv; + const linie = []; + for (const d of cv.degrees || []) { + const czesci = [d.name, d.year, d.institution].filter(Boolean); + if (czesci.length) linie.push(czesci.join(", ")); + } + for (const t of cv.titles || []) { + const czesci = [t.name, t.year].filter(Boolean); + if (czesci.length) linie.push(czesci.join(", ")); + } + bloki.push(blok("Stopnie i tytuły", linie)); + } + + bloki.push( + blok( + "Projekty naukowe", + (result.projects || []).map((p) => { + const meta = [p.competition, formatPln(p.funds), lata(p.startDate, p.endDate)] + .filter(Boolean) + .join(" · "); + return meta ? `${p.title} (${meta})` : p.title; + }), + ), + ); + + bloki.push( + blok( + "Patenty i prawa ochronne", + (result.patents || []).map((p) => + [p.title, p.type].filter(Boolean).join(" — "), + ), + ), + ); + + bloki.push( + blok( + "Osiągnięcia artystyczne", + (result.artisticAchievements || []).map((a) => { + const nagrody = (a.awards || []) + .map((w) => [w.competition, w.year].filter(Boolean).join(" ")) + .filter(Boolean) + .join(", "); + const baza = [a.title, a.year].filter(Boolean).join(", "); + return nagrody ? `${baza} — nagrody: ${nagrody}` : baza; + }), + ), + ); + + for (const b of bloki) if (b) wynik.appendChild(b); + container.hidden = false; +} + +/** + * Znajdź kontenery sekcji RAD-on w ``root``, dociągnij dane po ORCID i + * wyrenderuj. Zwraca Promise (allSettled-owe) — degraduje bez wyjątku. + * ``opts.fetchAchievements`` pozwala wstrzyknąć klienta (testy). + */ +export function wireRadonSections(root = document, opts = {}) { + const fetcher = + opts.fetchAchievements || + ((q) => fetchAchievementsByOrcid(createRadonClient(), q)); + + const kontenery = Array.from( + root.querySelectorAll("[data-radon-osiagniecia]"), + ); + + return Promise.all( + kontenery.map(async (container) => { + try { + const orcid = container.dataset.orcid || ""; + if (!orcid) return null; + const firstName = (container.dataset.imie || "").trim().split(/\s+/)[0] || ""; + const lastName = container.dataset.nazwisko || ""; + const result = await fetcher({ firstName, lastName, orcid }); + if (result && result.hasAny) { + renderRadonAchievements(container, result); + } + return result; + } catch (e) { + if (typeof console !== "undefined") { + console.debug("[radon] wireRadonSections", e); + } + return null; + } + }), + ); +} + +// Auto-wire przy załadowaniu strony (efekt uboczny dla bundla). +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => wireRadonSections()); + } else { + wireRadonSections(); + } +} diff --git a/src/bpp/templates/browse/autor_sekcje/radon_osiagniecia.html b/src/bpp/templates/browse/autor_sekcje/radon_osiagniecia.html new file mode 100644 index 000000000..25081db72 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/radon_osiagniecia.html @@ -0,0 +1,18 @@ +{# Osiągnięcia autora pobierane na żywo z RAD-on OpenData (client-side). #} +{# Bramka: tylko dla autorów z ORCID. Dane wstrzykuje radon-profil.js — #} +{# kontener startuje ukryty i pokazuje się dopiero, gdy klient znajdzie #} +{# rekord zweryfikowany po ORCID; przy braku danych/błędzie zostaje ukryty. #} +{% if autor.orcid %} + +
+{% endif %} diff --git a/src/bpp/tests/test_profil/test_radon.py b/src/bpp/tests/test_profil/test_radon.py new file mode 100644 index 000000000..93608a222 --- /dev/null +++ b/src/bpp/tests/test_profil/test_radon.py @@ -0,0 +1,40 @@ +"""Testy sekcji „Osiągnięcia (RAD-on)" na podstronie autora. + +Sekcja jest tylko-szablonowa (dane pobiera klient JS na żywo z RAD-on). Partial +bramkuje się po ``autor.orcid`` i renderuje kontener ``[data-radon-osiagniecia]`` +z ORCID-em, imieniem i nazwiskiem — resztę robi ``radon-profil.js`` w przeglądarce. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Autor +from bpp.profil_autora import KLUCZ_RADON, KOLUMNA_LEWA +from bpp.profil_autora_dane import przygotuj_sekcje + +pytestmark = pytest.mark.django_db + + +def test_radon_w_katalogu_lewej_kolumny(): + autor = baker.make(Autor) + sekcje = przygotuj_sekcje(autor, uczelnia=None, request=None) + klucze = [s["klucz"] for s in sekcje[KOLUMNA_LEWA]] + assert KLUCZ_RADON in klucze + + +def test_kontener_renderuje_sie_gdy_autor_ma_orcid(client): + autor = baker.make( + Autor, imiona="Przemysław", nazwisko="Kowalczewski", + orcid="0000-0002-0153-4624", + ) + tresc = client.get(autor.get_absolute_url()).content.decode() + assert "data-radon-osiagniecia" in tresc + assert 'data-orcid="0000-0002-0153-4624"' in tresc + assert 'data-nazwisko="Kowalczewski"' in tresc + assert "informacje pobrane z RAD-on" in tresc + + +def test_brak_kontenera_gdy_autor_bez_orcid(client): + autor = baker.make(Autor, nazwisko="Bezorcidowy", orcid="") + tresc = client.get(autor.get_absolute_url()).content.decode() + assert "data-radon-osiagniecia" not in tresc diff --git a/tests/js/radon-profil.test.js b/tests/js/radon-profil.test.js new file mode 100644 index 000000000..2b212c33f --- /dev/null +++ b/tests/js/radon-profil.test.js @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +// +// Testy warstwy integracji RAD-on (radon-profil.js): renderowanie wyniku do +// kontenera sekcji + spinanie [data-radon-osiagniecia] z klientem RAD-on. +// Klient jest wstrzykiwany (opts.fetchAchievements) — nie ruszamy sieci. +import { describe, test, expect, beforeAll, beforeEach } from "vitest"; + +let renderRadonAchievements; +let wireRadonSections; + +beforeAll(async () => { + ({ renderRadonAchievements, wireRadonSections } = await import( + "../../src/bpp/static/bpp/js/radon-profil.js" + )); +}); + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +function makeContainer(dataset = {}) { + const el = document.createElement("div"); + el.setAttribute("data-radon-osiagniecia", ""); + el.dataset.orcid = dataset.orcid ?? "0000-0002-0153-4624"; + el.dataset.imie = dataset.imie ?? "Przemysław Jan"; + el.dataset.nazwisko = dataset.nazwisko ?? "Kowalczewski"; + el.hidden = true; + const wynik = document.createElement("div"); + wynik.className = "radon-osiagniecia__wynik"; + el.appendChild(wynik); + document.body.appendChild(el); + return el; +} + +const SAMPLE = { + hasAny: true, + cv: { + level: "dr hab. inż.", + degrees: [ + { name: "Doktor", year: "2016", institution: "UP Poznań", discipline: null }, + ], + titles: [], + education: [], + employment: [], + sources: "POLON", + }, + projects: [ + { title: "Badanie soku", competition: "MINIATURA", funds: 47861, startDate: "2019", endDate: "2020" }, + ], + patents: [{ title: "Sposób kapsułkowania", type: "Wynalazek" }], + artisticAchievements: [], +}; + +describe("renderRadonAchievements", () => { + test("wypełnia kontener i pokazuje go", () => { + const el = makeContainer(); + renderRadonAchievements(el, SAMPLE); + const txt = el.querySelector(".radon-osiagniecia__wynik").textContent; + expect(txt).toContain("Doktor"); + expect(txt).toContain("Badanie soku"); + expect(txt).toContain("zł"); + expect(txt).toContain("Sposób kapsułkowania"); + expect(el.hidden).toBe(false); + }); + + test("renderuje bez innerHTML (żadne surowe pole nie tworzy elementów)", () => { + const el = makeContainer(); + renderRadonAchievements(el, { + ...SAMPLE, + projects: [{ title: "", funds: null }], + }); + const wynik = el.querySelector(".radon-osiagniecia__wynik"); + // Treść trafia jako tekst, więc żaden nie powstaje. + expect(wynik.querySelector("img")).toBeNull(); + expect(wynik.textContent).toContain(""); + }); +}); + +describe("wireRadonSections", () => { + test("parsuje imię/nazwisko/orcid i renderuje przy hasAny", async () => { + const el = makeContainer(); + let seen; + await wireRadonSections(document, { + fetchAchievements: async (q) => { + seen = q; + return SAMPLE; + }, + }); + expect(seen).toEqual({ + firstName: "Przemysław", + lastName: "Kowalczewski", + orcid: "0000-0002-0153-4624", + }); + expect(el.hidden).toBe(false); + }); + + test("zostaje ukryty gdy brak danych (hasAny=false)", async () => { + const el = makeContainer(); + await wireRadonSections(document, { + fetchAchievements: async () => ({ hasAny: false, projects: [], patents: [], artisticAchievements: [], cv: null }), + }); + expect(el.hidden).toBe(true); + }); + + test("degraduje bez wyjątku gdy fetch rzuci", async () => { + const el = makeContainer(); + await expect( + wireRadonSections(document, { + fetchAchievements: async () => { + throw new Error("network down"); + }, + }), + ).resolves.toBeDefined(); + expect(el.hidden).toBe(true); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2fdf2b31d..14417d13b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -194,6 +194,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@iplweb/radon-opendata@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@iplweb/radon-opendata/-/radon-opendata-0.1.1.tgz#534716546cd7233781c2c3e1a669f5a525f1ba3a" + integrity sha512-GfH7TCEfC8F6JizdTYrHxblQPn2d112n6vn1XzrR1nvXhD38OGqiBec+VlkNpnyoewUeROLzEVFy9SkcOoINRg== + "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"