diff --git a/jest.config.js b/jest.config.js index 4553afa..2239657 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,4 +10,5 @@ export default { moduleNameMapper: { "^webextension-polyfill$": "/tests/__mocks__/browser.ts", }, + silent: true, }; diff --git a/src/js/main.ts b/src/js/main.ts index 1d05468..89817a6 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -1,6 +1,6 @@ import { TeachingUnitRepository } from "./teaching_unit/repository"; import { TeachingUnitView } from "./teaching_unit/view"; -import { ValidationError } from "./teaching_unit/model"; +import { TeachingUnit, ValidationError } from "./teaching_unit/model"; function getCurriculum() { const curriculum = document.getElementById("parcours"); @@ -18,7 +18,10 @@ const curriculum = getCurriculum(); const teachingUnitElements = getTeachingUnitElements(curriculum); teachingUnitElements.forEach(async (el: Element) => { try { - const teachingUnit = await TeachingUnitRepository.getFromLocalStorage(el); + const teachingUnit = TeachingUnit.fromElement(el); + teachingUnit.state = + await TeachingUnitRepository.getStateFromLocalStorage(teachingUnit); + new TeachingUnitRepository(teachingUnit); new TeachingUnitView(el, teachingUnit); } catch (error) { diff --git a/src/js/teaching_unit/repository.ts b/src/js/teaching_unit/repository.ts index 07b0dae..86469e5 100644 --- a/src/js/teaching_unit/repository.ts +++ b/src/js/teaching_unit/repository.ts @@ -10,31 +10,26 @@ export class TeachingUnitRepository implements Observer { public update(subject: TeachingUnit): void { switch (subject.state) { case State.Unselected: - this.removeFromLocalStorage(subject); + TeachingUnitRepository.removeStateFromLocalStorage(subject); break; case State.Selected: case State.Validated: - this.saveToLocalStorage(subject); + TeachingUnitRepository.saveStateToLocalStorage(subject); break; } } - public static async getFromLocalStorage(el: Element) { - const teachingUnit = TeachingUnit.fromElement(el); - const record = await browser.storage.local.get(teachingUnit.code); - const state = record[teachingUnit.code]; - switch (state) { - case State.Selected: - teachingUnit.select(); - break; - case State.Validated: - teachingUnit.validate(); - break; + public static async getStateFromLocalStorage(subject: TeachingUnit) { + const record = await browser.storage.local.get(subject.code); + const state = record[subject.code]; + if (state) { + return state; + } else { + return State.Unselected; } - return teachingUnit; } - private removeFromLocalStorage(subject: TeachingUnit) { + public static removeStateFromLocalStorage(subject: TeachingUnit) { browser.storage.local.remove(subject.code).then( () => { console.debug(`${subject.code} removed from local storage`); @@ -45,7 +40,7 @@ export class TeachingUnitRepository implements Observer { ); } - private saveToLocalStorage(subject: TeachingUnit) { + public static saveStateToLocalStorage(subject: TeachingUnit) { const record = {}; record[subject.code] = subject.state; browser.storage.local.set(record).then( diff --git a/tests/__mocks__/webextension-polyfill.ts b/tests/__mocks__/webextension-polyfill.ts index 469c7e0..86f464c 100644 --- a/tests/__mocks__/webextension-polyfill.ts +++ b/tests/__mocks__/webextension-polyfill.ts @@ -1,40 +1,9 @@ -declare global { - // eslint-disable-next-line no-var - var myLocalStorage: object; -} - export const browser = { storage: { local: { - get: function (code: string) { - if (globalThis.myLocalStorage == undefined) { - globalThis.myLocalStorage = {}; - } - if (code in globalThis.myLocalStorage) { - return Promise.resolve(globalThis.myLocalStorage[code]); - } - return {}; - }, - set: function (obj: object) { - if (globalThis.myLocalStorage == undefined) { - globalThis.myLocalStorage = {}; - } - const code = Object.keys(obj)[0]; - const state = Object.values(obj)[0]; - const record = {}; - record[code] = state; - globalThis.myLocalStorage[code] = record; - return Promise.resolve(); - }, - remove: function (code: string) { - if (globalThis.myLocalStorage == undefined) { - globalThis.myLocalStorage = {}; - } - if (code in globalThis.myLocalStorage) { - delete globalThis.myLocalStorage[code]; - } - return Promise.resolve(); - }, + get: jest.fn(), + set: jest.fn().mockImplementation(() => Promise.resolve()), + remove: jest.fn().mockImplementation(() => Promise.resolve()), }, }, }; diff --git a/tests/teaching_unit.test.ts b/tests/teaching_unit.test.ts index 2197a83..30d4b77 100644 --- a/tests/teaching_unit.test.ts +++ b/tests/teaching_unit.test.ts @@ -1,164 +1,118 @@ import { State, TeachingUnit } from "../src/js/teaching_unit/model"; -import { Observer } from "../src/js/utils"; - -test("get teaching unit from element", () => { - // Set up our document body - document.body.innerHTML = - '
' + - '
' + - '
' + - '
6 ECTS
' + - '

' + - ' Mathematical tools for computing' + - "

" + - '
' + - ' MVA003' + - "
" + - "
" + - "
" + - "
"; - const el = document.getElementsByClassName("ue")[0]; - const teachingUnit = TeachingUnit.fromElement(el); - expect(teachingUnit.code).toBe("MVA003"); - expect(teachingUnit.title).toBe("Mathematical tools for computing"); - expect(teachingUnit.ects).toBe(6); -}); - -test("get teaching unit from element with missing title", () => { - // Set up our document body - document.body.innerHTML = - '
' + - '
' + - '
' + - '
6 ECTS
' + - '
' + - ' MVA003' + - "
" + - "
" + - "
" + - "
"; - const el = document.getElementsByClassName("ue")[0]; - - expect(() => { - TeachingUnit.fromElement(el); - }).toThrow("missing field: title"); -}); - -test("get teaching unit from element with missing code", () => { - // Set up our document body - document.body.innerHTML = - '
' + - '
' + - '
' + - '

' + - ' Mathematical tools for computing' + - "

" + - '
6 ECTS
' + - "
" + - "
" + - "
"; - const el = document.getElementsByClassName("ue")[0]; - - expect(() => { - TeachingUnit.fromElement(el); - }).toThrow("missing field: code"); -}); +import { DummyObserver, mockCallback } from "./utils.test"; -test("get teaching unit from element with missing credits", () => { - // Set up our document body - document.body.innerHTML = - '
' + - '
' + - '
' + - '

' + - ' Mathematical tools for computing' + - "

" + - '
' + - ' MVA003' + - "
" + - "
" + - "
" + - "
"; - const el = document.getElementsByClassName("ue")[0]; - - expect(() => { - TeachingUnit.fromElement(el); - }).toThrow("missing field: credits"); -}); - -test("toggle teaching unit", () => { - const teachingUnit = new TeachingUnit( - "Architecture des machines", - "NFA004", - 4, - ); - - expect(teachingUnit.state).toBe(State.Unselected); - teachingUnit.toggle(); - expect(teachingUnit.state).toBe(State.Selected); - teachingUnit.toggle(); - expect(teachingUnit.state).toBe(State.Validated); - teachingUnit.toggle(); - expect(teachingUnit.state).toBe(State.Unselected); -}); +function newHTMLTeachingUnit(title?: string, code?: string, ects?: number) { + let html = + '
'; -test("observe teaching unit", () => { - class DummyObserver implements Observer { - update(subject: TeachingUnit): void { - subject.state = State.Unselected; - } + if (ects != null) { + html += `
${ects} ECTS
`; } - - const obs = new DummyObserver(); - const teachingUnit = new TeachingUnit( - "Architecture des machines", - "NFA004", - 4, - ); - - teachingUnit.attach(obs); - expect(teachingUnit.state).toBe(State.Unselected); - teachingUnit.toggle(); - - expect(teachingUnit.state).toBe(State.Unselected); - - teachingUnit.detach(obs); - teachingUnit.toggle(); - expect(teachingUnit.state).toBe(State.Selected); -}); - -test("detach nonexistent observer", () => { - class DummyObserver implements Observer { - update(subject: TeachingUnit): void { - subject.state = State.Unselected; - } + if (title != null) { + html += + '

' + + ` ${title}` + + "

"; } - const obs = new DummyObserver(); - const teachingUnit = new TeachingUnit( - "Architecture des machines", - "NFA004", - 4, - ); - - expect(() => { - teachingUnit.detach(obs); - }).toThrow("Nonexistent observer"); -}); - -test("attach twice observer", () => { - class DummyObserver implements Observer { - update(subject: TeachingUnit): void { - subject.state = State.Unselected; - } + if (code != null) { + html += + '
' + + ` ${code}` + + "
"; } - const obs = new DummyObserver(); - const teachingUnit = new TeachingUnit( - "Architecture des machines", - "NFA004", - 4, - ); - teachingUnit.attach(obs); - expect(() => { + + html += "
"; + + return html; +} + +describe("TeachingUnit", () => { + describe("given a valid html element", () => { + test("returns a new valid instance", () => { + document.body.innerHTML = newHTMLTeachingUnit( + "Mathematical tools for computing", + "MVA003", + 6, + ); + const el = document.getElementsByClassName("ue")[0]; + + const teachingUnit = TeachingUnit.fromElement(el); + + expect(teachingUnit.code).toBe("MVA003"); + expect(teachingUnit.title).toBe("Mathematical tools for computing"); + expect(teachingUnit.ects).toBe(6); + }); + }); + + describe("given an invalid html element", () => { + test("with missing title throws ValidationError", () => { + document.body.innerHTML = newHTMLTeachingUnit(null, "MVA003", 6); + + const el = document.getElementsByClassName("ue")[0]; + + expect(() => { + TeachingUnit.fromElement(el); + }).toThrow("missing field: title"); + }); + + test("with missing code throws ValidationError", () => { + document.body.innerHTML = newHTMLTeachingUnit( + "Mathematical tools for computing", + null, + 6, + ); + + const el = document.getElementsByClassName("ue")[0]; + + expect(() => { + TeachingUnit.fromElement(el); + }).toThrow("missing field: code"); + }); + + test("with missing credits throws ValidationError", () => { + document.body.innerHTML = newHTMLTeachingUnit( + "Mathematical tools for computing", + "MVA003", + null, + ); + + const el = document.getElementsByClassName("ue")[0]; + + expect(() => { + TeachingUnit.fromElement(el); + }).toThrow("missing field: credits"); + }); + }); + + test.each([ + [State.Unselected, State.Selected], + [State.Selected, State.Validated], + [State.Validated, State.Unselected], + ])("next state after %d to be %d", (currentState, nextState) => { + const teachingUnit = new TeachingUnit( + "Architecture des machines", + "NFA004", + 4, + currentState, + ); + + teachingUnit.toggle(); + + expect(teachingUnit.state).toBe(nextState); + }); + + test("notify observer on toggle", () => { + const obs = new DummyObserver(); + const teachingUnit = new TeachingUnit( + "Architecture des machines", + "NFA004", + 4, + ); + teachingUnit.attach(obs); - }).toThrow("Observer has been attached already"); + teachingUnit.toggle(); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(teachingUnit); + }); }); diff --git a/tests/teaching_unit_repository.test.ts b/tests/teaching_unit_repository.test.ts index 1f39814..fa5b012 100644 --- a/tests/teaching_unit_repository.test.ts +++ b/tests/teaching_unit_repository.test.ts @@ -1,41 +1,77 @@ import { State, TeachingUnit } from "../src/js/teaching_unit/model"; import { TeachingUnitRepository } from "../src/js/teaching_unit/repository"; +import browser from "./__mocks__/webextension-polyfill"; -test("save and delete from/to local storage", () => { - document.body.innerHTML = - '
' + - '
' + - '
' + - '
6 ECTS
' + - '

' + - ' Mathematical tools for computing' + - "

" + - '
' + - ' MVA003' + - "
" + - "
" + - "
" + - "
"; - const el = document.getElementsByClassName("ue")[0]; - const teachingUnit = TeachingUnit.fromElement(el); +test("save state to local storage", () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Validated); - new TeachingUnitRepository(teachingUnit); - TeachingUnitRepository.getFromLocalStorage(el).then((teaching_unit) => { - expect(teaching_unit.state).toBe(State.Unselected); + TeachingUnitRepository.saveStateToLocalStorage(teachingUnit); + + expect(browser.storage.local.set).toHaveBeenCalledWith({ + UTC505: State.Validated, }); +}); + +test("get existing state from local storage", async () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Validated); + browser.storage.local.get.mockImplementation(() => + Promise.resolve({ UTC505: State.Validated }), + ); + + const state = + await TeachingUnitRepository.getStateFromLocalStorage(teachingUnit); + + expect(state).toBe(State.Validated); + expect(browser.storage.local.get).toHaveBeenCalledWith("UTC505"); +}); + +test("get nonexisting state from local storage", async () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Unselected); + browser.storage.local.get.mockImplementation(() => Promise.resolve({})); + + const state = + await TeachingUnitRepository.getStateFromLocalStorage(teachingUnit); + + expect(state).toBe(State.Unselected); + expect(browser.storage.local.get).toHaveBeenCalledWith("UTC505"); +}); + +test("remove state from local storage", () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Validated); + TeachingUnitRepository.saveStateToLocalStorage(teachingUnit); + + TeachingUnitRepository.removeStateFromLocalStorage(teachingUnit); + + expect(browser.storage.local.remove).toHaveBeenCalledWith("UTC505"); +}); + +test("remove state is called on update when state is unselected", () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Validated); + new TeachingUnitRepository(teachingUnit); teachingUnit.toggle(); - TeachingUnitRepository.getFromLocalStorage(el).then((teaching_unit) => { - expect(teaching_unit.state).toBe(State.Selected); - }); + + expect(browser.storage.local.remove).toHaveBeenCalledWith("UTC505"); +}); + +test("save state is called on update when state is selected", () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Unselected); + new TeachingUnitRepository(teachingUnit); teachingUnit.toggle(); - TeachingUnitRepository.getFromLocalStorage(el).then((teaching_unit) => { - expect(teaching_unit.state).toBe(State.Validated); + + expect(browser.storage.local.set).toHaveBeenCalledWith({ + UTC505: State.Selected, }); +}); + +test("save state is called on update when state is validated", () => { + const teachingUnit = new TeachingUnit("foo", "UTC505", 3, State.Selected); + new TeachingUnitRepository(teachingUnit); teachingUnit.toggle(); - TeachingUnitRepository.getFromLocalStorage(el).then((teaching_unit) => { - expect(teaching_unit.state).toBe(State.Unselected); + + expect(browser.storage.local.set).toHaveBeenCalledWith({ + UTC505: State.Validated, }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..42af515 --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,38 @@ +import { AbstractSubject, Observer, Subject } from "../src/js/utils"; + +export const mockCallback = jest.fn(); + +export class DummyObserver implements Observer { + update(subject: Subject): void { + mockCallback(subject); + } +} + +export class DummySubject extends AbstractSubject { + toggle() { + this.notify(); + } +} + +test("detaching nonexistent observer throws Error", () => { + const obs = new DummyObserver(); + const subject = new DummySubject(); + + subject.attach(obs); + subject.detach(obs); + + expect(() => { + subject.detach(obs); + }).toThrow("Nonexistent observer"); +}); + +test("attaching twice the same observer throws Error", () => { + const obs = new DummyObserver(); + const subject = new DummySubject(); + + subject.attach(obs); + + expect(() => { + subject.attach(obs); + }).toThrow("Observer has been attached already"); +});