Compare commits

..

4 Commits

Author SHA1 Message Date
undergroundwires
af7219f6e1 Fix tree node check states not being updated
This commit fixes a bug where the check states of tree nodes were not
correctly updated upon selecting predefined groups like "Standard",
"Strict", "None" and "All".

It resolves the issue by manually triggering of updates for mutated
array containing selected scripts.

It enhances test coverage to prevent regression and verify the correct
behavior of state updates.

This bug was introduced in commit
4995e49c46, which optimized reactivity by
removing deep state tracking.
2023-11-07 01:14:38 +01:00
undergroundwires
8ccaec7af6 Fix unresponsive copy button on instructions modal
This commit fixes the bug where the "Copy" button does not copy when
clicked on download instructions modal (on Linux and macOS).

This commit also introduces several improvements to the UI components
related to copy action and their interaction with the clipboard feature.

It adds more tests to avoid regression of the bugs and improves
maintainability, testability and adherence to Vue's reactive principles.

Changes include:

- Fix non-responsive copy button in the download instructions modal by
  triggering a `click` event in `AppIcon.vue`.
- Improve `TheCodeButtons.vue`:
  - Remove redundant `getCurrentCode` function.
  - Separate components for each button for better separation of
    concerns and higher testability.
  - Use the `gap` property in the flexbox layout, replacing the less
    explicit sibling combinator approach.
- Add `useClipboard` compositional hook for more idiomatic Vue approach
  to interacting with the clipboard.
- Add `useCurrentCode` compositional hook to handle current code state
  more effectively with unified logic.
- Abstract clipboard operations to an interface to isolate
  responsibilities.
- Switch clipboard implementation to the `navigator.clipboard` API,
  moving away from the deprecated `document.execCommand`.
- Move clipboard logic to the presentation layer to conform to
  separation of concerns and domain-driven design principles.
- Improve `IconButton.vue` component to increase reusability with
  consistent sizing.
2023-11-06 21:55:43 +01:00
undergroundwires
b2ffc90da7 Add winget download instructions 2023-11-05 17:05:17 +01:00
undergroundwires-bot
72e4d0b896 ⬆️ bump everywhere to 0.12.6 2023-11-04 12:14:46 +00:00
44 changed files with 1145 additions and 246 deletions

View File

@@ -1,5 +1,30 @@
# Changelog
## 0.12.6 (2023-11-03)
* Bump dependencies to latest | [25d7f7b](https://github.com/undergroundwires/privacy.sexy/commit/25d7f7b2a479e51e092881cc2751e67a7d3f179f)
* win: improve system app uninstall cleanup #73 | [dbe3c5c](https://github.com/undergroundwires/privacy.sexy/commit/dbe3c5cfb91ba8a1657838b69117858843c8fbc8)
* win: improve system app uninstall /w fallback #260 | [98a26f9](https://github.com/undergroundwires/privacy.sexy/commit/98a26f9ae47af2668aa53f39d1768983036048ce)
* Improve performance of rendering during search | [79b46bf](https://github.com/undergroundwires/privacy.sexy/commit/79b46bf21004d96d31551439e5db5d698a3f71f3)
* Fix YAML error for site release in CI/CD | [237d994](https://github.com/undergroundwires/privacy.sexy/commit/237d9944f900f5172366868d75219224ff0542b0)
* win: fix Microsoft Advertising app removal #200 | [e40b9a3](https://github.com/undergroundwires/privacy.sexy/commit/e40b9a3cf53c341f2e84023a9f0e9680ac08f3fa)
* win: improve directory cleanup security | [060e789](https://github.com/undergroundwires/privacy.sexy/commit/060e7896624309aebd25e8b190c127282de177e8)
* Centralize Electron entry file path configuration | [d6da406](https://github.com/undergroundwires/privacy.sexy/commit/d6da406c61e5b9f5408851d1302d6d7398157a2e)
* win: prevent updates from reinstalling apps #260 | [8570b02](https://github.com/undergroundwires/privacy.sexy/commit/8570b02dde14ffad64863f614682c3fc1f87b6c2)
* win: improve script environment robustness #221 | [dfd4451](https://github.com/undergroundwires/privacy.sexy/commit/dfd44515613f38abe5a806bda36f44e7b715b50b)
* Fix compiler failing with nested `with` expression | [80821fc](https://github.com/undergroundwires/privacy.sexy/commit/80821fca0769e5fd2c6338918fbdcea12fbe83d2)
* win: improve soft file/app delete security #260 | [f4a74f0](https://github.com/undergroundwires/privacy.sexy/commit/f4a74f058db9b5bcbcbe438785db5ec88ecc1657)
* Fix incorrect tooltip position after window resize | [f8e5f1a](https://github.com/undergroundwires/privacy.sexy/commit/f8e5f1a5a2afa1f18567e6d965359b6a1f082367)
* linux: fix string formatting of Firefox configs | [e775d68](https://github.com/undergroundwires/privacy.sexy/commit/e775d68a9b4a5f9e893ff0e3500dade036185193)
* win: improve file delete | [e72c1c1](https://github.com/undergroundwires/privacy.sexy/commit/e72c1c13ea2d73ebfc7a8da5a21254fdfc0e5b59)
* win: change system app removal to hard delete #260 | [77123d8](https://github.com/undergroundwires/privacy.sexy/commit/77123d8c929d23676a9cb21d7b697703fd1b6e82)
* Improve UI performance by optimizing reactivity | [4995e49](https://github.com/undergroundwires/privacy.sexy/commit/4995e49c469211404dac9fcb79b75eb121f80bce)
* Migrate to Vue 3.0 #230 | [ca81f68](https://github.com/undergroundwires/privacy.sexy/commit/ca81f68ff1c3bbe5b22981096ae9220b0b5851c7)
* win, linux: unify & improve Firefox clean-up #273 | [0466b86](https://github.com/undergroundwires/privacy.sexy/commit/0466b86f1013341c966a9bbf6513990337b16598)
* win: fix store revert for multiple installs #260 | [5bb13e3](https://github.com/undergroundwires/privacy.sexy/commit/5bb13e34f8de2e2a7ba943ff72b12c0569435e62)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.5...0.12.6)
## 0.12.5 (2023-10-13)
* Fix Docker build and improve checks #220 | [7669985](https://github.com/undergroundwires/privacy.sexy/commit/7669985f8e1446e726a95626ecf35b3ce6b60a16)

View File

@@ -122,7 +122,7 @@
## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-Setup-0.12.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.AppImage). For more options, see [here](#additional-install-options).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-Setup-0.12.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.AppImage). For more options, see [here](#additional-install-options).
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
@@ -153,12 +153,21 @@ Online version does not require to run any software on your computer. Offline ve
## Additional Install Options
- Check the [releases page](https://github.com/undergroundwires/privacy.sexy/releases) for all available versions.
- Using [Scoop](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) package manager on Windows:
- Other unofficial channels (not maintained by privacy.sexy) for Windows include:
- [Scoop 🥄](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) (latest version):
```powershell
scoop bucket add extras
scoop install privacy.sexy
```
```powershell
scoop bucket add extras
scoop install privacy.sexy
```
- [winget 🪟](https://winget.run/pkg/undergroundwires/privacy.sexy) (may be outdated):
```powershell
winget install -e --id undergroundwires.privacy.sexy
```
With winget, updates require manual submission; the auto-update feature within privacy.sexy will notify you of new releases post-installation.
## Development

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "privacy.sexy",
"version": "0.12.5",
"version": "0.12.6",
"lockfileVersion": 2,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "privacy.sexy",
"version": "0.12.5",
"version": "0.12.6",
"private": true,
"slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",

View File

@@ -1,13 +0,0 @@
export class Clipboard {
public static copyText(text: string): void {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', ''); // to avoid focus
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
}

View File

@@ -5,6 +5,8 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { useClipboard } from '../components/Shared/Hooks/Clipboard/UseClipboard';
import { useCurrentCode } from '../components/Shared/Hooks/UseCurrentCode';
export function provideDependencies(
context: IApplicationContext,
@@ -23,6 +25,12 @@ export function provideDependencies(
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
return useCollectionState(context, events);
});
registerTransient(InjectionKeys.useClipboard, () => useClipboard());
registerTransient(InjectionKeys.useCurrentCode, () => {
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
const state = api.inject(InjectionKeys.useCollectionState)();
return useCurrentCode(state, events);
});
}
export interface VueDependencyInjectionApi {

View File

@@ -0,0 +1,33 @@
<template>
<IconButton
text="Copy"
@click="copyCode"
icon-name="copy"
/>
</template>
<script lang="ts">
import {
defineComponent, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import IconButton from './IconButton.vue';
export default defineComponent({
components: {
IconButton,
},
setup() {
const { copyText } = inject(InjectionKeys.useClipboard)();
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
async function copyCode() {
await copyText(currentCode.value);
}
return {
copyCode,
};
},
});
</script>

View File

@@ -0,0 +1,59 @@
<template>
<IconButton
v-if="canRun"
text="Run"
@click="executeCode"
icon-name="play"
/>
</template>
<script lang="ts">
import {
defineComponent, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import IconButton from './IconButton.vue';
export default defineComponent({
components: {
IconButton,
},
setup() {
const { currentState, currentContext } = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
async function executeCode() {
await runCode(currentContext);
}
return {
isDesktopVersion: isDesktop,
canRun,
executeCode,
};
},
});
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>

View File

@@ -1,15 +1,17 @@
<template>
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
<div class="button-wrapper">
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
</div>
</template>
<script lang="ts">
@@ -49,10 +51,20 @@ export default defineComponent({
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.button-wrapper {
position: relative;
height: 70px;
.button {
position: absolute;
width: 100%;
height: 100%;
}
}
.button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: $color-secondary;
color: $color-on-secondary;
@@ -70,19 +82,17 @@ export default defineComponent({
@include clickable;
width: 10%;
min-width: 90px;
@include hover-or-touch {
background: $color-surface;
box-shadow: 0px 2px 10px 5px $color-secondary;
.button__text {
display: block;
}
.button__icon {
display: none;
}
}
@include hover-or-touch('>&__text') {
display: block;
}
@include hover-or-touch('>&__icon') {
display: none;
}
&__text {
.button__text {
display: none;
font-family: $font-artistic;
font-size: 1.5em;

View File

@@ -0,0 +1,95 @@
<template>
<div>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
@click="saveCode"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" />
</ModalDialog>
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import IconButton from '../IconButton.vue';
import InstructionList from './Instructions/InstructionList.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
export default defineComponent({
components: {
IconButton,
InstructionList,
ModalDialog,
},
setup() {
const { currentState } = inject(InjectionKeys.useCollectionState)();
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const areInstructionsVisible = ref(false);
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
areInstructionsVisible.value = true;
}
return {
isDesktopVersion: isDesktop,
instructions,
fileName,
areInstructionsVisible,
saveCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
</script>

View File

@@ -16,10 +16,10 @@
</template>
<script lang="ts">
import { defineComponent, shallowRef } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard';
import { defineComponent, shallowRef, inject } from 'vue';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export default defineComponent({
components: {
@@ -27,9 +27,11 @@ export default defineComponent({
AppIcon,
},
setup() {
const { copyText } = inject(InjectionKeys.useClipboard)();
const codeElement = shallowRef<HTMLElement | undefined>();
function copyCode() {
async function copyCode() {
const element = codeElement.value;
if (!element) {
throw new Error('Code element could not be found.');
@@ -38,7 +40,7 @@ export default defineComponent({
if (!code) {
throw new Error('Code element does not contain any text.');
}
Clipboard.copyText(code);
await copyText(code);
}
return {

View File

@@ -1,168 +1,36 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
v-if="canRun"
text="Run"
v-on:click="executeCode"
icon-name="play"
/>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCode"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<IconButton
text="Copy"
v-on:click="copyCode"
icon-name="copy"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" />
</ModalDialog>
<CodeRunButton class="code-button" />
<CodeSaveButton class="code-button" />
<CodeCopyButton class="code-button" />
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, computed, inject,
defineComponent, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import InstructionList from './Instructions/InstructionList.vue';
import IconButton from './IconButton.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
import CodeRunButton from './CodeRunButton.vue';
import CodeCopyButton from './CodeCopyButton.vue';
import CodeSaveButton from './Save/CodeSaveButton.vue';
export default defineComponent({
components: {
IconButton,
InstructionList,
ModalDialog,
CodeRunButton,
CodeCopyButton,
CodeSaveButton,
},
setup() {
const {
currentState, currentContext, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
const areInstructionsVisible = ref(false);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const hasCode = ref(false);
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
async function copyCode() {
const code = await getCurrentCode();
Clipboard.copyText(code.current);
}
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
areInstructionsVisible.value = true;
}
async function executeCode() {
await runCode(currentContext);
}
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(code: string) {
hasCode.value = code && code.length > 0;
}
async function getCurrentCode(): Promise<IApplicationCode> {
const { code } = currentContext.state;
return code;
}
const hasCode = computed<boolean>(() => currentCode.value.length > 0);
return {
isDesktopVersion: isDesktop,
canRun,
hasCode,
instructions,
fileName,
areInstructionsVisible,
copyCode,
saveCode,
executeCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>
<style scoped lang="scss">
@@ -170,8 +38,10 @@ async function runCode(context: IReadOnlyApplicationContext) {
display: flex;
flex-direction: row;
justify-content: center;
gap: 30px;
}
.container > * + * {
margin-left: 30px;
.code-button {
width: 10%;
min-width: 90px;
}
</style>

View File

@@ -1,5 +1,5 @@
import {
computed, inject, shallowReadonly, shallowRef,
computed, inject, shallowReadonly, shallowRef, triggerRef,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
@@ -25,11 +25,21 @@ function useSelectedScripts() {
const selectedScripts = shallowRef<readonly SelectedScript[]>([]);
function updateSelectedScripts(newReference: readonly SelectedScript[]) {
if (selectedScripts.value === newReference) {
// Manually trigger update if the array was mutated using the same reference.
// Array might have been mutated without changing the reference
triggerRef(selectedScripts);
} else {
selectedScripts.value = newReference;
}
}
onStateChange((state) => {
selectedScripts.value = state.selection.selectedScripts;
updateSelectedScripts(state.selection.selectedScripts);
events.unsubscribeAllAndRegister([
state.selection.changed.on((scripts) => {
selectedScripts.value = scripts;
updateSelectedScripts(scripts);
}),
]);
}, { immediate: true });

View File

@@ -0,0 +1,15 @@
import { Clipboard } from './Clipboard';
export type NavigatorClipboard = typeof globalThis.navigator.clipboard;
export class BrowserClipboard implements Clipboard {
constructor(
private readonly navigatorClipboard: NavigatorClipboard = globalThis.navigator.clipboard,
) {
}
public async copyText(text: string): Promise<void> {
await this.navigatorClipboard.writeText(text);
}
}

View File

@@ -0,0 +1,3 @@
export interface Clipboard {
copyText(text: string): Promise<void>;
}

View File

@@ -0,0 +1,13 @@
import { FunctionKeys } from '@/TypeHelpers';
import { BrowserClipboard } from './BrowserClipboard';
import { Clipboard } from './Clipboard';
export function useClipboard(clipboard: Clipboard = new BrowserClipboard()) {
// Bind functions for direct use from destructured assignments such as `const { .. } = ...`.
const functionKeys: readonly FunctionKeys<Clipboard>[] = ['copyText'];
functionKeys.forEach((functionName) => {
const fn = clipboard[functionName];
clipboard[functionName] = fn.bind(clipboard);
});
return clipboard;
}

View File

@@ -0,0 +1,32 @@
import { ref } from 'vue';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { useCollectionState } from './UseCollectionState';
export function useCurrentCode(
state: ReturnType<typeof useCollectionState>,
events: IEventSubscriptionCollection,
) {
const { onStateChange } = state;
const currentCode = ref<string>('');
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(newCode: string) {
currentCode.value = newCode;
}
return {
currentCode,
};
}

View File

@@ -1,5 +1,9 @@
<template>
<div v-html="svgContent" class="inline-icon" />
<div
class="inline-icon"
v-html="svgContent"
@click="onClicked"
/>
</template>
<script lang="ts">
@@ -18,10 +22,19 @@ export default defineComponent({
required: true,
},
},
setup(props) {
emits: [
'click',
],
setup(props, { emit }) {
const useSvgLoaderHook = inject('useSvgLoaderHook', useSvgLoader);
const { svgContent } = useSvgLoaderHook(() => props.icon);
return { svgContent };
function onClicked() {
emit('click');
}
return { svgContent, onClicked };
},
});

View File

@@ -1,7 +1,9 @@
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
import type { useAutoUnsubscribedEvents } from './components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import type { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import type { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { InjectionKey } from 'vue';
export const InjectionKeys = {
@@ -9,6 +11,8 @@ export const InjectionKeys = {
useApplication: defineSingletonKey<ReturnType<typeof useApplication>>('useApplication'),
useRuntimeEnvironment: defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment'),
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
};
function defineSingletonKey<T>(key: string): InjectionKey<T> {

View File

@@ -14,6 +14,8 @@ describe('DependencyProvider', () => {
useApplication: createSingletonTests(),
useRuntimeEnvironment: createSingletonTests(),
useAutoUnsubscribedEvents: createTransientTests(),
useClipboard: createTransientTests(),
useCurrentCode: createTransientTests(),
};
Object.entries(testCases).forEach(([key, runTests]) => {
describe(`Key: "${key}"`, () => {

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import CodeCopyButton from '@/presentation/components/Code/CodeButtons/CodeCopyButton.vue';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
import { UseCurrentCodeStub } from '@tests/unit/shared/Stubs/UseCurrentCodeStub';
const COMPONENT_ICON_BUTTON_WRAPPER_NAME = 'IconButton';
describe('CodeCopyButton', () => {
it('copies current code when clicked', async () => {
// arrange
const expectedCode = 'code to be copied';
const clipboard = new ClipboardStub();
const wrapper = mountComponent({
clipboard,
currentCode: expectedCode,
});
// act
await wrapper.trigger('click');
// assert
const calls = clipboard.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'copyText');
expect(call).toBeDefined();
const [copiedText] = call.args;
expect(copiedText).to.equal(expectedCode);
});
});
function mountComponent(options?: {
clipboard?: Clipboard,
currentCode?: string,
}) {
return shallowMount(CodeCopyButton, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]: () => (
options?.clipboard
? new UseClipboardStub(options.clipboard)
: new UseClipboardStub()
).get(),
[InjectionKeys.useCurrentCode as symbol]: () => (
options.currentCode === undefined
? new UseCurrentCodeStub()
: new UseCurrentCodeStub().withCurrentCode(options.currentCode)
).get(),
},
stubs: {
[COMPONENT_ICON_BUTTON_WRAPPER_NAME]: {
name: COMPONENT_ICON_BUTTON_WRAPPER_NAME,
template: '<div @click="handleClick()" />',
emits: ['click'],
setup: (_, { emit }) => ({
handleClick: () => emit('click'),
}),
},
},
},
});
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import CodeInstruction from '@/presentation/components/Code/CodeButtons/Save/Instructions/CodeInstruction.vue';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
const DOM_SELECTOR_CODE_SLOT = 'code';
const DOM_SELECTOR_COPY_BUTTON = '.copy-button';
const COMPONENT_TOOLTIP_WRAPPER_NAME = 'TooltipWrapper';
describe('CodeInstruction.vue', () => {
it('renders a slot content inside a <code> element', () => {
// arrange
const expectedSlotContent = 'Example Code';
const wrapper = mountComponent({
slotContent: expectedSlotContent,
});
// act
const codeSlot = wrapper.find(DOM_SELECTOR_CODE_SLOT);
const actualContent = codeSlot.text();
// assert
expect(actualContent).to.equal(expectedSlotContent);
});
describe('copy', () => {
it('calls copyText when the copy button is clicked', async () => {
// arrange
const expectedCode = 'Code to be copied';
const clipboardStub = new ClipboardStub();
const wrapper = mountComponent({
clipboard: clipboardStub,
});
wrapper.vm.codeElement = { textContent: expectedCode } as HTMLElement;
// act
const copyButton = wrapper.find(DOM_SELECTOR_COPY_BUTTON);
await copyButton.trigger('click');
// assert
const calls = clipboardStub.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'copyText');
expect(call).toBeDefined();
const [actualCode] = call.args;
expect(actualCode).to.equal(expectedCode);
});
it('throws an error when codeElement is not found during copy', async () => {
// arrange
const expectedError = 'Code element could not be found.';
const wrapper = mountComponent();
wrapper.vm.codeElement = undefined;
// act
const act = () => wrapper.vm.copyCode();
// assert
await expectThrowsAsync(act, expectedError);
});
it('throws an error when codeElement has no textContent during copy', async () => {
// arrange
const expectedError = 'Code element does not contain any text.';
const wrapper = mountComponent();
wrapper.vm.codeElement = { textContent: '' } as HTMLElement;
// act
const act = () => wrapper.vm.copyCode();
// assert
await expectThrowsAsync(act, expectedError);
});
});
});
function mountComponent(options?: {
readonly clipboard?: Clipboard,
readonly slotContent?: string,
}) {
return shallowMount(CodeInstruction, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]:
() => {
if (options?.clipboard) {
return new UseClipboardStub(options.clipboard).get();
}
return new UseClipboardStub().get();
},
},
stubs: {
[COMPONENT_TOOLTIP_WRAPPER_NAME]: {
name: COMPONENT_TOOLTIP_WRAPPER_NAME,
template: '<slot />',
},
},
},
slots: {
default: options?.slotContent ?? 'Stubbed slot content',
},
});
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListData';
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListData';
describe('InstructionsBuilder', () => {
describe('withStep', () => {

View File

@@ -1,6 +1,6 @@
import { describe } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/MacOsInstructionsBuilder';
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/MacOsInstructionsBuilder';
import { runOsSpecificInstructionBuilderTests } from './OsSpecificInstructionBuilderTestRunner';
describe('MacOsInstructionsBuilder', () => {

View File

@@ -1,6 +1,6 @@
import { it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
interface ITestData {
readonly factory: () => InstructionsBuilder;

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListDataFactory';
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory';
import { getEnumValues } from '@/application/Common/Enum';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
describe('InstructionListDataFactory', () => {
const supportedOsList = [OperatingSystem.macOS];

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { nextTick, watch } from 'vue';
import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
@@ -8,9 +9,10 @@ import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCo
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { IScript } from '@/domain/IScript';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
describe('useSelectedScriptNodeIds', () => {
it('returns empty array when no scripts are selected', () => {
it('returns an empty array when no scripts are selected', () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
useStateStub.withState(new CategoryCollectionStateStub().withSelectedScripts([]));
@@ -19,38 +21,213 @@ describe('useSelectedScriptNodeIds', () => {
// assert
expect(actualIds).to.have.lengthOf(0);
});
it('returns correct node IDs for selected scripts', () => {
it('initially registers the unsubscribe callback', () => {
// arrange
const selectedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
];
const parsedNodeIds = new Map<IScript, string>([
[selectedScripts[0].script, 'expected-id-1'],
[selectedScripts[1].script, 'expected-id-1'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
scriptNodeIdParser: (script) => parsedNodeIds.get(script),
});
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts),
immediateOnly: true,
});
const eventsStub = new UseAutoUnsubscribedEventsStub();
// act
const actualIds = returnObject.selectedScriptNodeIds.value;
mountWrapperComponent({
useAutoUnsubscribedEvents: eventsStub,
});
// assert
const expectedNodeIds = [...parsedNodeIds.values()];
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
const calls = eventsStub.events.callHistory;
expect(eventsStub.events.callHistory).has.lengthOf(1);
const call = calls.find((c) => c.methodName === 'unsubscribeAllAndRegister');
expect(call).toBeDefined();
});
describe('returns correct node IDs for selected scripts', () => {
it('immediately', () => {
// arrange
const selectedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
];
const parsedNodeIds = new Map<IScript, string>([
[selectedScripts[0].script, 'expected-id-1'],
[selectedScripts[1].script, 'expected-id-2'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
});
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts),
immediateOnly: true,
});
// act
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
const expectedNodeIds = [...parsedNodeIds.values()];
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
});
it('when the collection state changes', () => {
// arrange
const initialScripts = [];
const changedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
];
const parsedNodeIds = new Map<IScript, string>([
[changedScripts[0].script, 'expected-id-1'],
[changedScripts[1].script, 'expected-id-2'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
});
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(initialScripts),
immediateOnly: true,
});
// act
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(changedScripts),
immediateOnly: false,
});
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
const expectedNodeIds = [...parsedNodeIds.values()];
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
});
it('when the selection state changes', () => {
// arrange
const initialScripts = [];
const changedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
];
const parsedNodeIds = new Map<IScript, string>([
[changedScripts[0].script, 'expected-id-1'],
[changedScripts[1].script, 'expected-id-2'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
});
const userSelection = new UserSelectionStub([])
.withSelectedScripts(initialScripts);
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub()
.withSelection(userSelection),
immediateOnly: true,
});
// act
userSelection.triggerSelectionChangedEvent(changedScripts);
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
const expectedNodeIds = [...parsedNodeIds.values()];
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
});
});
describe('reactivity to state changes', () => {
describe('when the collection state changes', () => {
it('with new array references', async () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub(),
immediateOnly: false,
});
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
it('with the same array reference', async () => {
// arrange
const sharedSelectedScriptsReference = [];
const initialCollectionState = new CategoryCollectionStateStub()
.withSelectedScripts(sharedSelectedScriptsReference);
const changedCollectionState = new CategoryCollectionStateStub()
.withSelectedScripts(sharedSelectedScriptsReference);
const { useStateStub, returnObject } = mountWrapperComponent();
useStateStub.triggerOnStateChange({
newState: initialCollectionState,
immediateOnly: true,
});
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
useStateStub.triggerOnStateChange({
newState: changedCollectionState,
immediateOnly: false,
});
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
});
describe('when the selection state changes', () => {
it('with new array references', async () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
const userSelection = new UserSelectionStub([])
.withSelectedScripts([]);
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub()
.withSelection(userSelection),
immediateOnly: true,
});
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
userSelection.triggerSelectionChangedEvent([]);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
it('with the same array reference', async () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
const sharedSelectedScriptsReference = [];
const userSelection = new UserSelectionStub([])
.withSelectedScripts(sharedSelectedScriptsReference);
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub()
.withSelection(userSelection),
immediateOnly: true,
});
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
});
});
});
type ScriptNodeIdParser = typeof getScriptNodeId;
function createNodeIdParserFromMap(scriptToIdMap: Map<IScript, string>): ScriptNodeIdParser {
return (script) => {
const expectedId = scriptToIdMap.get(script);
if (!expectedId) {
throw new Error(`No mapped ID for script: ${JSON.stringify(script)}`);
}
return expectedId;
};
}
function mountWrapperComponent(scenario?: {
readonly scriptNodeIdParser?: typeof getScriptNodeId,
readonly scriptNodeIdParser?: ScriptNodeIdParser,
readonly useAutoUnsubscribedEvents?: UseAutoUnsubscribedEventsStub,
}) {
const useStateStub = new UseCollectionStateStub();
const nodeIdParser: typeof getScriptNodeId = scenario?.scriptNodeIdParser
const nodeIdParser: ScriptNodeIdParser = scenario?.scriptNodeIdParser
?? ((script) => script.id);
let returnObject: ReturnType<typeof useSelectedScriptNodeIds>;
@@ -65,7 +242,7 @@ function mountWrapperComponent(scenario?: {
[InjectionKeys.useCollectionState as symbol]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
},
},
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { BrowserClipboard, NavigatorClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
describe('BrowserClipboard', () => {
describe('writeText', () => {
it('calls navigator clipboard with the correct text', async () => {
// arrange
const expectedText = 'test text';
const navigatorClipboard = new NavigatorClipboardStub();
const clipboard = new BrowserClipboard(navigatorClipboard);
// act
await clipboard.copyText(expectedText);
// assert
const calls = navigatorClipboard.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'writeText');
expect(call).toBeDefined();
const [actualText] = call.args;
expect(actualText).to.equal(expectedText);
});
it('throws when navigator clipboard fails', async () => {
// arrange
const expectedError = 'internalError';
const navigatorClipboard = new NavigatorClipboardStub();
navigatorClipboard.writeText = () => {
throw new Error(expectedError);
};
const clipboard = new BrowserClipboard(navigatorClipboard);
// act
const act = () => clipboard.copyText('unimportant-text');
// assert
await expectThrowsAsync(act, expectedError);
});
});
});
class NavigatorClipboardStub
extends StubWithObservableMethodCalls<NavigatorClipboard>
implements NavigatorClipboard {
writeText(data: string): Promise<void> {
this.registerMethodCall({
methodName: 'writeText',
args: [data],
});
return Promise.resolve();
}
read(): Promise<ClipboardItems> {
throw new Error('Method not implemented.');
}
readText(): Promise<string> {
throw new Error('Method not implemented.');
}
write(): Promise<void> {
throw new Error('Method not implemented.');
}
addEventListener(): void {
throw new Error('Method not implemented.');
}
dispatchEvent(): boolean {
throw new Error('Method not implemented.');
}
removeEventListener(): void {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
import { BrowserClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
import { FunctionKeys } from '@/TypeHelpers';
describe('useClipboard', () => {
it(`returns an instance of ${BrowserClipboard.name}`, () => {
// arrange
const expectedType = BrowserClipboard;
// act
const clipboard = useClipboard();
// assert
expect(clipboard).to.be.instanceOf(expectedType);
});
it('does not create a new instance if one is provided', () => {
// arrange
const expectedClipboard = new ClipboardStub();
// act
const actualClipboard = useClipboard(expectedClipboard);
// assert
expect(actualClipboard).to.equal(expectedClipboard);
});
describe('supports object destructuring', () => {
type ClipboardFunction = FunctionKeys<ReturnType<typeof useClipboard>>;
const testScenarios: {
readonly [FunctionName in ClipboardFunction]:
Parameters<ReturnType<typeof useClipboard>[FunctionName]>;
} = {
copyText: ['text-arg'],
};
Object.entries(testScenarios).forEach(([functionName, testFunctionArgs]) => {
describe(functionName, () => {
it('binds the method to the instance', () => {
// arrange
const expectedArgs = testFunctionArgs;
const clipboardStub = new ClipboardStub();
// act
const clipboard = useClipboard(clipboardStub);
const { [functionName as ClipboardFunction]: testFunction } = clipboard;
// assert
testFunction(...expectedArgs);
const call = clipboardStub.callHistory.find((c) => c.methodName === functionName);
expect(call).toBeDefined();
expect(call.args).to.deep.equal(expectedArgs);
});
it('ensures method retains the clipboard instance context', () => {
// arrange
const clipboardStub = new ClipboardStub();
const expectedThisContext = clipboardStub;
let actualThisContext: typeof expectedThisContext | undefined;
// eslint-disable-next-line func-names
clipboardStub[functionName] = function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
actualThisContext = this;
};
// act
const clipboard = useClipboard(clipboardStub);
const { [functionName as ClipboardFunction]: testFunction } = clipboard;
// assert
testFunction(...testFunctionArgs);
expect(expectedThisContext).to.equal(actualThisContext);
});
});
});
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import { ApplicationCodeStub } from '@tests/unit/shared/Stubs/ApplicationCodeStub';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { EventSubscriptionCollectionStub } from '@tests/unit/shared/Stubs/EventSubscriptionCollectionStub';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { CodeChangedEventStub } from '@tests/unit/shared/Stubs/CodeChangedEventStub';
describe('useCurrentCode', () => {
describe('currentCode', () => {
it('gets code from initial state', () => {
// arrange
const expectedCode = 'initial code';
const { codeStub, collectionStateStub } = createStubs();
codeStub.withCurrentCode(expectedCode);
const useCollectionStateStub = new UseCollectionStateStub();
const { currentCode } = new UseCurrentCodeBuilder()
.withUseCollectionState(useCollectionStateStub)
.build();
// act
useCollectionStateStub.triggerOnStateChange({
newState: collectionStateStub,
immediateOnly: true, // set initial state
});
// assert
const actualCode = currentCode.value;
expect(actualCode).to.equal(expectedCode);
});
it('updates code state code is changed', () => {
// arrange
const initialCode = 'initial code';
const expectedCode = 'changed code';
const {
codeStub: initialCodeStub,
collectionStateStub: initialCollectionStateStub,
} = createStubs();
initialCodeStub.withCurrentCode(initialCode);
const useCollectionStateStub = new UseCollectionStateStub();
const { currentCode } = new UseCurrentCodeBuilder()
.withUseCollectionState(useCollectionStateStub)
.build();
useCollectionStateStub.triggerOnStateChange({
newState: initialCollectionStateStub,
immediateOnly: true, // set initial state
});
const {
codeStub: changedCodeStub,
collectionStateStub: changedStateStub,
} = createStubs();
changedCodeStub.withCurrentCode(expectedCode);
// act
useCollectionStateStub.triggerOnStateChange({
newState: changedStateStub,
immediateOnly: true, // update state
});
// assert
const actualCode = currentCode.value;
expect(actualCode).to.equal(expectedCode);
});
it('updates code when code is changed', () => {
// arrange
const expectedCode = 'changed code';
const { codeStub, collectionStateStub } = createStubs();
const { currentCode } = new UseCurrentCodeBuilder()
.withCollectionState(collectionStateStub)
.build();
// act
codeStub.triggerCodeChange(new CodeChangedEventStub().withCode(expectedCode));
// assert
const actualCode = currentCode.value;
expect(expectedCode).to.equal(actualCode);
});
it('registers event subscription on creation', () => {
// arrange
const eventsStub = new EventSubscriptionCollectionStub();
const stateStub = new UseCollectionStateStub();
// act
new UseCurrentCodeBuilder()
.withUseCollectionState(stateStub)
.withEvents(eventsStub)
.build();
// assert
const calls = eventsStub.callHistory;
expect(calls).has.lengthOf(1);
const call = calls.find((c) => c.methodName === 'unsubscribeAllAndRegister');
expect(call).toBeDefined();
});
});
});
function createStubs() {
const codeStub = new ApplicationCodeStub();
const collectionStateStub = new CategoryCollectionStateStub().withCode(codeStub);
const useStateStub = new UseCollectionStateStub()
.withState(collectionStateStub);
return {
codeStub,
useStateStub,
collectionStateStub,
};
}
class UseCurrentCodeBuilder {
private useCollectionState: UseCollectionStateStub = new UseCollectionStateStub();
private events: IEventSubscriptionCollection = new EventSubscriptionCollectionStub();
public withUseCollectionState(useCollectionState: UseCollectionStateStub): this {
this.useCollectionState = useCollectionState;
return this;
}
public withCollectionState(collectionState: CategoryCollectionStateStub): this {
return this.withUseCollectionState(
this.useCollectionState.withState(collectionState),
);
}
public withEvents(events: IEventSubscriptionCollection): this {
this.events = events;
return this;
}
public build(): ReturnType<typeof useCurrentCode> {
return useCurrentCode(this.useCollectionState.get(), this.events);
}
}

View File

@@ -55,19 +55,31 @@ describe('AppIcon.vue', () => {
`Expected:\n\n${expectedSvg}\n\nActual:\n\n${actualSvg}`,
);
});
it('emits `click` event when clicked', async () => {
// arrange
const wrapper = mountComponent();
// act
await wrapper.trigger('click');
// assert
expect(wrapper.emitted().click).to.have.lengthOf(1);
});
});
function mountComponent(options: {
readonly iconPropValue: IconName,
readonly loader: UseSvgLoaderStub,
function mountComponent(options?: {
readonly iconPropValue?: IconName,
readonly loader?: UseSvgLoaderStub,
}) {
const iconName = options?.iconPropValue ?? 'globe';
const loaderStub = options?.loader ?? new UseSvgLoaderStub().withSvgIcon(iconName, '<svg />');
return shallowMount(AppIcon, {
props: {
icon: options.iconPropValue,
icon: iconName,
},
global: {
provide: {
useSvgLoaderHook: options.loader.get(),
useSvgLoaderHook: loaderStub.get(),
},
},
});

View File

@@ -1,10 +1,19 @@
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { EventSourceStub } from './EventSourceStub';
export class ApplicationCodeStub implements IApplicationCode {
public changed: IEventSource<ICodeChangedEvent> = new EventSource<ICodeChangedEvent>();
public changed = new EventSourceStub<ICodeChangedEvent>();
public current = '';
public triggerCodeChange(event: ICodeChangedEvent): this {
this.changed.notify(event);
return this;
}
public withCurrentCode(currentCode: string): this {
this.current = currentCode;
return this;
}
}

View File

@@ -14,7 +14,7 @@ import { ApplicationCodeStub } from './ApplicationCodeStub';
import { CategoryStub } from './CategoryStub';
export class CategoryCollectionStateStub implements ICategoryCollectionState {
public readonly code: IApplicationCode = new ApplicationCodeStub();
public code: IApplicationCode = new ApplicationCodeStub();
public filter: IUserFilter = new UserFilterStub();
@@ -39,6 +39,11 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
return this;
}
public withCode(code: IApplicationCode): this {
this.code = code;
return this;
}
public withOs(os: OperatingSystem): this {
if (this.collection instanceof CategoryCollectionStub) {
this.collection = this.collection.withOs(os);

View File

@@ -0,0 +1,14 @@
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ClipboardStub
extends StubWithObservableMethodCalls<Clipboard>
implements Clipboard {
public copyText(text: string): Promise<void> {
this.registerMethodCall({
methodName: 'copyText',
args: [text],
});
return Promise.resolve();
}
}

View File

@@ -0,0 +1,26 @@
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { IScript } from '@/domain/IScript';
export class CodeChangedEventStub implements ICodeChangedEvent {
public code: string;
public addedScripts: readonly IScript[];
public removedScripts: readonly IScript[];
public changedScripts: readonly IScript[];
public isEmpty(): boolean {
throw new Error('Method not implemented.');
}
public getScriptPositionInCode(): ICodePosition {
throw new Error('Method not implemented.');
}
public withCode(code: string): this {
this.code = code;
return this;
}
}

View File

@@ -0,0 +1,17 @@
import { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { ClipboardStub } from './ClipboardStub';
export class UseClipboardStub
extends StubWithObservableMethodCalls<ReturnType<typeof useClipboard>> {
constructor(private readonly clipboard: Clipboard = new ClipboardStub()) {
super();
}
public get(): ReturnType<typeof useClipboard> {
const { clipboard } = this;
clipboard.copyText = clipboard.copyText.bind(clipboard);
return this.clipboard;
}
}

View File

@@ -0,0 +1,17 @@
import { ref } from 'vue';
import { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
export class UseCurrentCodeStub {
public currentCodeRef = ref('');
public withCurrentCode(code: string): this {
this.currentCodeRef.value = code;
return this;
}
public get(): ReturnType<typeof useCurrentCode> {
return {
currentCode: this.currentCodeRef,
};
}
}

View File

@@ -1,15 +1,13 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScript } from '@/domain/IScript';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { EventSourceStub } from './EventSourceStub';
export class UserSelectionStub
extends StubWithObservableMethodCalls<IUserSelection>
implements IUserSelection {
public readonly changed: IEventSource<readonly SelectedScript[]> = new EventSource<
readonly SelectedScript[]>();
public readonly changed = new EventSourceStub<readonly SelectedScript[]>();
public selectedScripts: readonly SelectedScript[] = [];
@@ -22,6 +20,11 @@ export class UserSelectionStub
return this;
}
public triggerSelectionChangedEvent(scripts: readonly SelectedScript[]): this {
this.changed.notify(scripts);
return this;
}
public areAllSelected(): boolean {
throw new Error('Method not implemented.');
}