mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
feat: 为词表编辑器增加可选行号与语法高亮 (#546)
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
import ace from 'ace-builds'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import '@/ace-config'
|
||||||
|
|
||||||
|
interface AceToken {
|
||||||
|
type: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordListSyntaxMode {
|
||||||
|
getTokenizer: () => {
|
||||||
|
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
|
||||||
|
const tokenizer = new WordListSyntaxMode().getTokenizer()
|
||||||
|
|
||||||
|
function tokenize(line: string) {
|
||||||
|
return tokenizer.getLineTokens(line, 'start').tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('word list syntax mode', () => {
|
||||||
|
it('highlights a block word as one field', () => {
|
||||||
|
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('separates replaced and replacement fields without parsing their content', () => {
|
||||||
|
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
|
||||||
|
{ type: 'word_list_replaced', value: '旧名.*' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||||
|
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('separates front, back, and episode offset fields', () => {
|
||||||
|
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
|
||||||
|
{ type: 'word_list_front', value: '第' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' <> ' },
|
||||||
|
{ type: 'word_list_back', value: '集' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' >> ' },
|
||||||
|
{ type: 'word_list_offset', value: 'EP+1' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses all six field types in combined and standalone rules', () => {
|
||||||
|
const combinedTokens = tokenize('旧名 => 新名 && 第 <> 集 >> 2*EP-1')
|
||||||
|
|
||||||
|
expect(combinedTokens).toEqual([
|
||||||
|
{ type: 'word_list_replaced', value: '旧名' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||||
|
{ type: 'word_list_replacement', value: '新名' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' && ' },
|
||||||
|
{ type: 'word_list_front', value: '第' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' <> ' },
|
||||||
|
{ type: 'word_list_back', value: '集' },
|
||||||
|
{ type: 'keyword.operator.word-list', value: ' >> ' },
|
||||||
|
{ type: 'word_list_offset', value: '2*EP-1' },
|
||||||
|
])
|
||||||
|
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -578,6 +578,90 @@ function registerWordListMode() {
|
|||||||
exports.Mode = Mode
|
exports.Mode = Mode
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
aceModule.define?.(
|
||||||
|
'ace/mode/word_list_syntax_highlight_rules',
|
||||||
|
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||||
|
(require: any, exports: any) => {
|
||||||
|
const oop = require('../lib/oop')
|
||||||
|
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||||
|
|
||||||
|
const WordListSyntaxHighlightRules = function (this: any) {
|
||||||
|
this.$rules = {
|
||||||
|
start: [
|
||||||
|
{
|
||||||
|
token: 'comment.word-list',
|
||||||
|
regex: /^#.*/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: [
|
||||||
|
'text',
|
||||||
|
'word_list_replaced',
|
||||||
|
'keyword.operator.word-list',
|
||||||
|
'word_list_replacement',
|
||||||
|
'keyword.operator.word-list',
|
||||||
|
'word_list_front',
|
||||||
|
'keyword.operator.word-list',
|
||||||
|
'word_list_back',
|
||||||
|
'keyword.operator.word-list',
|
||||||
|
'word_list_offset',
|
||||||
|
'text',
|
||||||
|
],
|
||||||
|
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
|
||||||
|
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: [
|
||||||
|
'text',
|
||||||
|
'word_list_front',
|
||||||
|
'keyword.operator.word-list',
|
||||||
|
'word_list_back',
|
||||||
|
'keyword.operator.word-list',
|
||||||
|
'word_list_offset',
|
||||||
|
'text',
|
||||||
|
],
|
||||||
|
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: ['text', 'word_list_block', 'text'],
|
||||||
|
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
this.normalizeRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
|
||||||
|
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
aceModule.define?.(
|
||||||
|
'ace/mode/word_list_syntax',
|
||||||
|
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
|
||||||
|
(require: any, exports: any) => {
|
||||||
|
const oop = require('../lib/oop')
|
||||||
|
const TextMode = require('./text').Mode
|
||||||
|
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
|
||||||
|
|
||||||
|
const Mode = function (this: any) {
|
||||||
|
TextMode.call(this)
|
||||||
|
this.HighlightRules = WordListSyntaxHighlightRules
|
||||||
|
}
|
||||||
|
|
||||||
|
oop.inherits(Mode, TextMode)
|
||||||
|
|
||||||
|
;(function (this: any) {
|
||||||
|
this.$id = 'ace/mode/word_list_syntax'
|
||||||
|
}).call(Mode.prototype)
|
||||||
|
|
||||||
|
exports.Mode = Mode
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||||
|
|||||||
@@ -2215,6 +2215,8 @@ export default {
|
|||||||
entryCount: '{count} entries',
|
entryCount: '{count} entries',
|
||||||
ruleCount: '{count} rules',
|
ruleCount: '{count} rules',
|
||||||
listLabel: 'Word list content (one rule per line)',
|
listLabel: 'Word list content (one rule per line)',
|
||||||
|
lineNumbers: 'Line numbers',
|
||||||
|
syntaxHighlighting: 'Syntax highlighting',
|
||||||
saved: 'Saved',
|
saved: 'Saved',
|
||||||
unsaved: 'Unsaved changes',
|
unsaved: 'Unsaved changes',
|
||||||
saveChanges: 'Save changes',
|
saveChanges: 'Save changes',
|
||||||
|
|||||||
@@ -2177,6 +2177,8 @@ export default {
|
|||||||
entryCount: '{count} 条',
|
entryCount: '{count} 条',
|
||||||
ruleCount: '共 {count} 条规则',
|
ruleCount: '共 {count} 条规则',
|
||||||
listLabel: '词表内容(每行一个规则)',
|
listLabel: '词表内容(每行一个规则)',
|
||||||
|
lineNumbers: '行号',
|
||||||
|
syntaxHighlighting: '语法高亮',
|
||||||
saved: '已保存',
|
saved: '已保存',
|
||||||
unsaved: '有未保存修改',
|
unsaved: '有未保存修改',
|
||||||
saveChanges: '保存更改',
|
saveChanges: '保存更改',
|
||||||
|
|||||||
@@ -2176,6 +2176,8 @@ export default {
|
|||||||
entryCount: '{count} 條',
|
entryCount: '{count} 條',
|
||||||
ruleCount: '共 {count} 條規則',
|
ruleCount: '共 {count} 條規則',
|
||||||
listLabel: '詞表內容(每行一個規則)',
|
listLabel: '詞表內容(每行一個規則)',
|
||||||
|
lineNumbers: '行號',
|
||||||
|
syntaxHighlighting: '語法高亮',
|
||||||
saved: '已保存',
|
saved: '已保存',
|
||||||
unsaved: '有未保存修改',
|
unsaved: '有未保存修改',
|
||||||
saveChanges: '保存更改',
|
saveChanges: '保存更改',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const { t } = useI18n()
|
|||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
const { global: globalTheme } = useTheme()
|
const { global: globalTheme } = useTheme()
|
||||||
|
|
||||||
|
const WORDS_LINE_NUMBERS_STORAGE_KEY = 'MP_WORDS_SHOW_LINE_NUMBERS'
|
||||||
|
const WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY = 'MP_WORDS_SYNTAX_HIGHLIGHTING'
|
||||||
|
|
||||||
type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords'
|
type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords'
|
||||||
type WordSectionKey = TextSectionKey | 'episodeRules'
|
type WordSectionKey = TextSectionKey | 'episodeRules'
|
||||||
|
|
||||||
@@ -45,16 +48,29 @@ const episodeFormatRules = ref<EpisodeFormatRule[]>([])
|
|||||||
const activeSection = ref<WordSectionKey>('identifiers')
|
const activeSection = ref<WordSectionKey>('identifiers')
|
||||||
const expandedHelp = ref<string | null>(null)
|
const expandedHelp = ref<string | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
|
||||||
|
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
|
||||||
|
|
||||||
|
const textEditorLanguage = computed(() => (showSyntaxHighlighting.value ? 'word_list_syntax' : 'word_list'))
|
||||||
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
||||||
const textEditorOptions = {
|
const textEditorOptions = computed(() => ({
|
||||||
fontSize: 13.6,
|
fontSize: 13.6,
|
||||||
highlightActiveLine: false,
|
highlightActiveLine: false,
|
||||||
scrollPastEnd: 0,
|
scrollPastEnd: 0,
|
||||||
showGutter: false,
|
showFoldWidgets: false,
|
||||||
|
showGutter: showLineNumbers.value,
|
||||||
|
showLineNumbers: showLineNumbers.value,
|
||||||
showPrintMargin: false,
|
showPrintMargin: false,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
}
|
}))
|
||||||
|
|
||||||
|
watch(showLineNumbers, value => {
|
||||||
|
localStorage.setItem(WORDS_LINE_NUMBERS_STORAGE_KEY, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showSyntaxHighlighting, value => {
|
||||||
|
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
const savedTextValues = reactive<Record<TextSectionKey, string>>({
|
const savedTextValues = reactive<Record<TextSectionKey, string>>({
|
||||||
identifiers: '',
|
identifiers: '',
|
||||||
@@ -483,13 +499,33 @@ onMounted(() => {
|
|||||||
<template v-if="isTextSection">
|
<template v-if="isTextSection">
|
||||||
<div class="words-field-meta">
|
<div class="words-field-meta">
|
||||||
<strong>{{ t('setting.words.listLabel') }}</strong>
|
<strong>{{ t('setting.words.listLabel') }}</strong>
|
||||||
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span>
|
<div class="words-field-actions">
|
||||||
|
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span>
|
||||||
|
<VSwitch
|
||||||
|
v-if="activeSection === 'identifiers'"
|
||||||
|
v-model="showLineNumbers"
|
||||||
|
class="words-editor-switch"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="t('setting.words.lineNumbers')"
|
||||||
|
/>
|
||||||
|
<VSwitch
|
||||||
|
v-if="activeSection === 'identifiers'"
|
||||||
|
v-model="showSyntaxHighlighting"
|
||||||
|
class="words-editor-switch"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="t('setting.words.syntaxHighlighting')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VAceEditor
|
<VAceEditor
|
||||||
v-if="activeSection === 'identifiers'"
|
v-if="activeSection === 'identifiers'"
|
||||||
v-model:value="activeTextValue"
|
v-model:value="activeTextValue"
|
||||||
lang="word_list"
|
:lang="textEditorLanguage"
|
||||||
:theme="textEditorTheme"
|
:theme="textEditorTheme"
|
||||||
:options="textEditorOptions"
|
:options="textEditorOptions"
|
||||||
:placeholder="activeTextPlaceholder"
|
:placeholder="activeTextPlaceholder"
|
||||||
@@ -856,6 +892,23 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.words-field-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-editor-switch {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-editor-switch :deep(.v-label) {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.words-textarea :deep(textarea) {
|
.words-textarea :deep(textarea) {
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -867,6 +920,15 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.words-text-editor {
|
.words-text-editor {
|
||||||
|
--words-token-block: #af00db;
|
||||||
|
--words-token-replaced: #001080;
|
||||||
|
--words-token-replacement: #a31515;
|
||||||
|
--words-token-front: #267f99;
|
||||||
|
--words-token-back: #795e26;
|
||||||
|
--words-token-offset: #098658;
|
||||||
|
--words-token-comment: #008000;
|
||||||
|
--words-token-operator: #000;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
block-size: 15.8rem;
|
block-size: 15.8rem;
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
|
border: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
|
||||||
@@ -876,6 +938,17 @@ onMounted(() => {
|
|||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.words-text-editor.ace-github-dark {
|
||||||
|
--words-token-block: #c586c0;
|
||||||
|
--words-token-replaced: #9cdcfe;
|
||||||
|
--words-token-replacement: #ce9178;
|
||||||
|
--words-token-front: #4ec9b0;
|
||||||
|
--words-token-back: #dcdcaa;
|
||||||
|
--words-token-offset: #b5cea8;
|
||||||
|
--words-token-comment: #6a9955;
|
||||||
|
--words-token-operator: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
.words-text-editor :deep(.ace_scroller),
|
.words-text-editor :deep(.ace_scroller),
|
||||||
.words-text-editor :deep(.ace_content),
|
.words-text-editor :deep(.ace_content),
|
||||||
.words-text-editor :deep(.ace_text-layer) {
|
.words-text-editor :deep(.ace_text-layer) {
|
||||||
@@ -884,10 +957,46 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.words-text-editor :deep(.ace_comment) {
|
.words-text-editor :deep(.ace_comment) {
|
||||||
color: rgb(var(--v-theme-success)) !important;
|
color: var(--words-token-comment) !important;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_gutter-layer) {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_gutter-cell) {
|
||||||
|
padding-inline: 0.35rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_word_list_block) {
|
||||||
|
color: var(--words-token-block);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_word_list_replaced) {
|
||||||
|
color: var(--words-token-replaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_word_list_replacement) {
|
||||||
|
color: var(--words-token-replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_word_list_front) {
|
||||||
|
color: var(--words-token-front);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_word_list_back) {
|
||||||
|
color: var(--words-token-back);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_word_list_offset) {
|
||||||
|
color: var(--words-token-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-text-editor :deep(.ace_keyword.ace_operator.ace_word-list) {
|
||||||
|
color: var(--words-token-operator);
|
||||||
|
}
|
||||||
|
|
||||||
.words-inline-hint {
|
.words-inline-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1124,9 +1233,21 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.words-field-meta {
|
.words-field-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
min-block-size: 2.75rem;
|
min-block-size: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.words-field-meta strong {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-field-actions {
|
||||||
|
gap: 0.5rem;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.words-textarea :deep(.v-field__input) {
|
.words-textarea :deep(.v-field__input) {
|
||||||
min-block-size: 18rem;
|
min-block-size: 18rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import WordsView from '@/views/system/WordsView.vue'
|
||||||
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: mocks.apiGet,
|
||||||
|
post: mocks.apiPost,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
error: mocks.toastError,
|
||||||
|
success: mocks.toastSuccess,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const AceEditorStub = defineComponent({
|
||||||
|
name: 'VAceEditor',
|
||||||
|
props: {
|
||||||
|
lang: { type: String, default: 'text' },
|
||||||
|
options: { type: Object, default: () => ({}) },
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
data-testid="words-ace-editor"
|
||||||
|
:data-lang="lang"
|
||||||
|
:data-show-gutter="String(Boolean(options.showGutter))"
|
||||||
|
:data-show-line-numbers="String(Boolean(options.showLineNumbers))"
|
||||||
|
:data-value="value"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renderWordsView() {
|
||||||
|
return renderWithProviders(WordsView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
VAceEditor: AceEditorStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WordsView editor preferences', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint.includes('EpisodeFormatRuleTable')) return Promise.resolve({ data: { value: [] } })
|
||||||
|
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
|
||||||
|
})
|
||||||
|
mocks.apiPost.mockResolvedValue({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps line numbers disabled when no preference is stored', async () => {
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
const switchControl = screen.getByRole('checkbox', { name: '行号' })
|
||||||
|
|
||||||
|
expect(switchControl).not.toBeChecked()
|
||||||
|
expect(editor).toHaveAttribute('data-show-gutter', 'false')
|
||||||
|
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
|
||||||
|
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||||
|
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
|
||||||
|
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
|
||||||
|
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the enabled preference from local storage', async () => {
|
||||||
|
localStorage.setItem('MP_WORDS_SHOW_LINE_NUMBERS', 'true')
|
||||||
|
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: '行号' })).toBeChecked()
|
||||||
|
expect(editor).toHaveAttribute('data-show-gutter', 'true')
|
||||||
|
expect(editor).toHaveAttribute('data-show-line-numbers', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an unrecognized stored preference as disabled', async () => {
|
||||||
|
localStorage.setItem('MP_WORDS_SHOW_LINE_NUMBERS', 'enabled')
|
||||||
|
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: '行号' })).not.toBeChecked()
|
||||||
|
expect(editor).toHaveAttribute('data-show-gutter', 'false')
|
||||||
|
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the syntax highlighting preference from local storage', async () => {
|
||||||
|
localStorage.setItem('MP_WORDS_SYNTAX_HIGHLIGHTING', 'true')
|
||||||
|
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
|
||||||
|
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates Ace options and persists the preference without changing content', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: '行号' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(editor).toHaveAttribute('data-show-gutter', 'true')
|
||||||
|
expect(editor).toHaveAttribute('data-show-line-numbers', 'true')
|
||||||
|
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBe('true')
|
||||||
|
})
|
||||||
|
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: '行号' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(editor).toHaveAttribute('data-show-gutter', 'false')
|
||||||
|
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
|
||||||
|
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBe('false')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
|
||||||
|
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
|
||||||
|
})
|
||||||
|
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||||
|
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the switch only for custom identifiers', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderWordsView()
|
||||||
|
await screen.findByTestId('words-ace-editor')
|
||||||
|
|
||||||
|
const releaseGroupButtons = screen.getAllByRole('button', { name: /自定义制作组\/字幕组/ })
|
||||||
|
await user.click(releaseGroupButtons[0])
|
||||||
|
|
||||||
|
expect(screen.queryByRole('checkbox', { name: '行号' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('checkbox', { name: '语法高亮' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('words-ace-editor')).not.toBeInTheDocument()
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user