diff --git a/src/presentation/assets/icons/lightbulb.svg b/src/presentation/assets/icons/lightbulb.svg new file mode 100644 index 00000000..4a9cd6d3 --- /dev/null +++ b/src/presentation/assets/icons/lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/presentation/assets/icons/square-check.svg b/src/presentation/assets/icons/square-check.svg new file mode 100644 index 00000000..8e7748a0 --- /dev/null +++ b/src/presentation/assets/icons/square-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/presentation/assets/icons/triangle-exclamation.svg b/src/presentation/assets/icons/triangle-exclamation.svg new file mode 100644 index 00000000..246e5096 --- /dev/null +++ b/src/presentation/assets/icons/triangle-exclamation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/presentation/assets/styles/_colors.scss b/src/presentation/assets/styles/_colors.scss index 3b164b3f..f184ac58 100644 --- a/src/presentation/assets/styles/_colors.scss +++ b/src/presentation/assets/styles/_colors.scss @@ -30,6 +30,10 @@ $color-on-surface : #4d5156; // Background | Appears behind scrollable content. $color-background : #e6ecf4; +$color-success : #4CAF50; +$color-danger : #F44336; +$color-caution : #FFC107; + /* Application-specific colors: These are tailored to the specific needs of the application and derived from the above theme colors. diff --git a/src/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.vue b/src/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.vue new file mode 100644 index 00000000..6f7603ff --- /dev/null +++ b/src/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.vue b/src/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.vue new file mode 100644 index 00000000..8cc8c755 --- /dev/null +++ b/src/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.vue b/src/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.vue new file mode 100644 index 00000000..02fa22a1 --- /dev/null +++ b/src/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/src/presentation/components/Scripts/Menu/Selector/TheSelector.vue b/src/presentation/components/Scripts/Menu/Selector/TheSelector.vue index 051ae56c..a582d424 100644 --- a/src/presentation/components/Scripts/Menu/Selector/TheSelector.vue +++ b/src/presentation/components/Scripts/Menu/Selector/TheSelector.vue @@ -8,9 +8,11 @@ @click="selectType(SelectionType.None)" /> @@ -22,11 +24,16 @@ @click="selectType(SelectionType.Standard)" /> @@ -38,11 +45,20 @@ @click="selectType(SelectionType.Strict)" /> @@ -54,11 +70,15 @@ @click="selectType(SelectionType.All)" /> @@ -74,12 +94,14 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection'; import MenuOptionList from '../MenuOptionList.vue'; import MenuOptionListItem from '../MenuOptionListItem.vue'; import { SelectionType, setCurrentSelectionType, getCurrentSelectionType } from './SelectionTypeHandler'; +import SelectionTypeDocumentation from './SelectionTypeDocumentation.vue'; export default defineComponent({ components: { MenuOptionList, MenuOptionListItem, TooltipWrapper, + SelectionTypeDocumentation, }, setup() { const { diff --git a/src/presentation/components/Shared/Icon/IconName.ts b/src/presentation/components/Shared/Icon/IconName.ts index 3d6450ca..7e1a5b8b 100644 --- a/src/presentation/components/Shared/Icon/IconName.ts +++ b/src/presentation/components/Shared/Icon/IconName.ts @@ -17,6 +17,9 @@ export const IconNames = [ 'file-arrow-down', 'floppy-disk', 'play', + 'lightbulb', + 'square-check', + 'triangle-exclamation', ] as const; export type IconName = typeof IconNames[number]; diff --git a/tests/unit/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.spec.ts b/tests/unit/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.spec.ts new file mode 100644 index 00000000..4d722bb6 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import CircleRating from '@/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.vue'; +import RatingCircle from '@/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.vue'; + +const MAX_RATING = 4; + +describe('CircleRating.vue', () => { + describe('number of RatingCircle components', () => { + it('renders correct number of RatingCircle components based on maxRating', () => { + // arrange + const expectedMaxRating = MAX_RATING; + const currentRating = MAX_RATING - 1; + + // act + const wrapper = shallowMount(CircleRating, { + propsData: { + rating: currentRating, + }, + }); + + // assert + const ratingCircles = wrapper.findAllComponents(RatingCircle); + expect(ratingCircles.length).to.equal(expectedMaxRating); + }); + it('renders the correct number of RatingCircle components for default rating', () => { + // arrange + const expectedMaxRating = MAX_RATING; + + // act + const wrapper = shallowMount(CircleRating); + + // assert + const ratingCircles = wrapper.findAllComponents(RatingCircle); + expect(ratingCircles.length).to.equal(expectedMaxRating); + }); + }); + + describe('rating logic', () => { + it('fills the correct number of RatingCircle components based on the provided rating', () => { + // arrange + const expectedTotalComponents = 3; + + // act + const wrapper = shallowMount(CircleRating, { + propsData: { + rating: expectedTotalComponents, + }, + }); + + // assert + const filledCircles = wrapper.findAllComponents(RatingCircle).filter((w) => w.props().filled); + expect(filledCircles.length).to.equal(expectedTotalComponents); + }); + + describe('validates rating correctly', () => { + const testCases = [ + { + value: -1, + expectedValidationResult: false, + }, + { + value: 0, + expectedValidationResult: true, + }, + { + value: MAX_RATING - 1, + expectedValidationResult: true, + }, + { + value: MAX_RATING, + expectedValidationResult: true, + }, + ]; + testCases.forEach((testCase) => { + it(`given ${testCase.value} return ${testCase.expectedValidationResult ? 'true' : 'false'}`, () => { + // arrange + const { validator } = CircleRating.props.rating; + + // act + const actualValidationResult = validator(testCase.value); + + // act + expect(actualValidationResult).to.equal(testCase.expectedValidationResult); + }); + }); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.spec.ts b/tests/unit/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.spec.ts new file mode 100644 index 00000000..49e2c747 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import RatingCircle from '@/presentation/components/Scripts/Menu/Selector/Rating/RatingCircle.vue'; + +const DOM_SVG_SELECTOR = 'svg'; +const DOM_CIRCLE_SELECTOR = `${DOM_SVG_SELECTOR} > circle`; +const DOM_CIRCLE_FILLED_SELECTOR = `${DOM_CIRCLE_SELECTOR}.filled`; + +describe('RatingCircle.vue', () => { + describe('circle appearance', () => { + it('renders a circle with the correct styles when filled', () => { + const wrapper = shallowMount(RatingCircle, { + propsData: { + filled: true, + }, + }); + + const circle = wrapper.find(DOM_CIRCLE_FILLED_SELECTOR); + expect(circle.exists()).to.equal(true); + }); + + it('renders a circle without filled styles when not filled', () => { + const wrapper = shallowMount(RatingCircle, { + propsData: { + filled: false, + }, + }); + + const circle = wrapper.find(DOM_CIRCLE_FILLED_SELECTOR); + expect(circle.exists()).to.equal(false); + }); + + it('renders without filled styles when filled prop is not provided', () => { + const wrapper = shallowMount(RatingCircle); + + const circle = wrapper.find(DOM_CIRCLE_FILLED_SELECTOR); + expect(circle.exists()).to.equal(false); + }); + }); + + describe('SVG and circle styles', () => { + it('sets --circle-stroke-width style correctly', () => { + const wrapper = shallowMount(RatingCircle); + const svgElement = wrapper.find(DOM_SVG_SELECTOR).element; + expect(svgElement.style.getPropertyValue('--circle-stroke-width')).to.equal('2px'); + }); + + it('renders circle with correct fill attribute when filled prop is true', () => { + const wrapper = shallowMount(RatingCircle, { + propsData: { + filled: true, + }, + }); + const circleElement = wrapper.find(DOM_CIRCLE_FILLED_SELECTOR); + + expect(circleElement.classes()).to.include('filled'); + }); + + it('renders circle with the correct viewBox property', () => { + const wrapper = shallowMount(RatingCircle); + const circle = wrapper.find(DOM_SVG_SELECTOR); + + expect(circle.attributes('viewBox')).to.equal('-1 -1 22 22'); + }); + }); + + describe('circle attributes', () => { + it('renders circle with the correct cx attribute', () => { + const wrapper = shallowMount(RatingCircle); + const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR); + + expect(circleElement.attributes('cx')).to.equal('10'); // Based on circleDiameterInPx = 20 + }); + + it('renders circle with the correct cy attribute', () => { + const wrapper = shallowMount(RatingCircle); + const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR); + + expect(circleElement.attributes('cy')).to.equal('10'); // Based on circleDiameterInPx = 20 + }); + + it('renders circle with the correct r attribute', () => { + const wrapper = shallowMount(RatingCircle); + const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR); + + expect(circleElement.attributes('r')).to.equal('9'); // Based on circleRadiusWithoutStrokeInPx = circleDiameterInPx / 2 - circleStrokeWidthInPx / 2 + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.spec.ts b/tests/unit/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.spec.ts new file mode 100644 index 00000000..bb175cc4 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.spec.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import SelectionTypeDocumentation from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeDocumentation.vue'; +import CircleRating from '@/presentation/components/Scripts/Menu/Selector/Rating/CircleRating.vue'; + +const DOM_SELECTOR_INCLUDES_SECTION = '.includes'; +const DOM_SELECTOR_CONSIDERATIONS_SECTION = '.considerations'; + +describe('SelectionTypeDocumentation.vue', () => { + it('renders privacy rating using CircleRating component', () => { + // arrange + const expectedPrivacyRating = 3; + + // act + const wrapper = mountComponent({ + privacyRating: expectedPrivacyRating, + }); + + // assert + const ratingComponent = wrapper.findComponent(CircleRating); + expect(ratingComponent.exists()).to.equal(true); + expect(ratingComponent.props().rating).to.equal(expectedPrivacyRating); + }); + + it('renders the provided description', () => { + // arrange + const expectedDescription = 'Some description'; + + // act + const wrapper = mountComponent({ + description: expectedDescription, + }); + + // assert + expect(wrapper.text()).to.include(expectedDescription); + }); + + it('renders the provided recommendation', () => { + // arrange + const expectedRecommendation = 'Some recommendation'; + + // act + const wrapper = mountComponent({ + recommendation: expectedRecommendation, + }); + + // assert + expect(wrapper.text()).to.include(expectedRecommendation); + }); + + describe('includes', () => { + it('renders items if provided', () => { + // arrange + const expectedIncludes = ['Item 1', 'Item 2']; + + // act + const wrapper = mountComponent({ + includes: expectedIncludes, + }); + + // assert + expect(wrapper.text()).to.include(expectedIncludes[0]); + expect(wrapper.text()).to.include(expectedIncludes[1]); + }); + + it('renders included section if provided', () => { + // arrange + // act + const wrapper = mountComponent({ + includes: ['some', 'includes'], + }); + + // assert + const includesSection = wrapper.find(DOM_SELECTOR_INCLUDES_SECTION); + expect(includesSection.exists()).to.equal(true); + }); + + it('does not render included section if no items provided', () => { + // arrange + // act + const wrapper = mountComponent({ + includes: [], + }); + + // assert + const includesSection = wrapper.find(DOM_SELECTOR_INCLUDES_SECTION); + expect(includesSection.exists()).to.equal(false); + }); + }); + + describe('considerations', () => { + it('renders if provided', () => { + // arrange + const expectedConsiderations = ['Consideration 1', 'Consideration 2']; + + // act + const wrapper = mountComponent({ + considerations: expectedConsiderations, + }); + + // assert + expect(wrapper.text()).to.include(expectedConsiderations[0]); + expect(wrapper.text()).to.include(expectedConsiderations[1]); + }); + + it('renders included section if provided', () => { + // arrange + // act + const wrapper = mountComponent({ + considerations: ['some', 'considerations'], + }); + + // assert + const considerationsSection = wrapper.find(DOM_SELECTOR_CONSIDERATIONS_SECTION); + expect(considerationsSection.exists()).to.equal(true); + }); + + it('does not render considerations section if no items provided', () => { + // arrange + // act + const wrapper = mountComponent({ + considerations: [], + }); + + // assert + const considerationsSection = wrapper.find(DOM_SELECTOR_CONSIDERATIONS_SECTION); + expect(considerationsSection.exists()).to.equal(false); + }); + }); +}); + +function mountComponent(options: { + readonly privacyRating?: number, + readonly description?: string, + readonly recommendation?: string, + readonly includes?: string[], + readonly considerations?: string[], +}) { + return shallowMount(SelectionTypeDocumentation, { + propsData: { + privacyRating: options.privacyRating ?? 0, + description: options.description ?? 'test-description', + recommendation: options.recommendation ?? 'test-recommendation', + considerations: options.considerations ?? [], + includes: options.includes ?? [], + }, + }); +}