diff --git a/assets/css/_darkmode.scss b/assets/css/_darkmode.scss index aea3fde3f..a5dd13135 100644 --- a/assets/css/_darkmode.scss +++ b/assets/css/_darkmode.scss @@ -329,4 +329,23 @@ $tooltip-shadow: #333; filter:invert(1) hue-rotate(180deg); } + .prp-qualitytext { + color:white; /* needed to have enough contrast */ + } + .prp-quality0 { + background-color:#55585e; + } + .prp-quality1 { + background-color:#971602; + } + .prp-quality2 { + background-color:#40408c; + } + .prp-quality3 { + background-color:#5C5C00; + } + .prp-quality4 { + background-color:#00662e; + } + } diff --git a/assets/css/application.scss b/assets/css/application.scss index fe2546220..e1dfda63a 100644 --- a/assets/css/application.scss +++ b/assets/css/application.scss @@ -741,7 +741,29 @@ a.help-icon { clear: both; } + +.prp-qualitytext { + border-radius: 3px; + padding: 0 2px; +} + +.prp-quality0 { + background-color:#ddd; +} +.prp-quality1 { + background-color:#ffabab; +} +.prp-quality2 { + background-color:#bbbbff; +} +.prp-quality3 { + background-color:#ffe867; +} +.prp-quality4 { + background-color:#90ff90; +} + .link-loading { pointer-events: none; font-style: italic; -} +} \ No newline at end of file diff --git a/assets/css/editcounter.scss b/assets/css/editcounter.scss index c0b68e609..f15700370 100644 --- a/assets/css/editcounter.scss +++ b/assets/css/editcounter.scss @@ -41,6 +41,10 @@ height: auto; width: auto; } + + div.chart-wrapper.qualitychangechart { + float:none; + } .top-project-edit-counts td { white-space: nowrap; diff --git a/i18n/en.json b/i18n/en.json index ed9ad522c..cb910e33d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -331,6 +331,8 @@ "priority": "Priority", "project": "Project", "projects": "Projects", + "proofreadpage-quality": "Page status", + "proofreadpage-qualitychanges": "Proportions of ProofreadPage quality changes", "prose": "Prose", "protect": "Protect", "proxy-check": "Proxy check", diff --git a/i18n/qqq.json b/i18n/qqq.json index 9a3a256c2..7d8769a47 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -351,6 +351,8 @@ "priority": "Label for the priority of a 'bug' in a wiki page that needs fixing.\n{{Identical|Priority}}", "project": "Header for the part of the interface that lets the user select which project they want to view statistics for.\n{{Identical|Project}}", "projects": "Label for a number of projects.\n{{Identical|Projects}}", + "proofreadpage-quality": "Label for the transcription status of a page, as recorded by Extension:ProofreadPage.", + "proofreadpage-qualitychanges": "Label for a chart of the proportions of the different quality changes.", "prose": "Term used to describe the text content of an article.", "protect": "Name of the MediaWiki log action 'protect', as in page protection.\n{{Identical|Protect}}", "proxy-check": "Label for link to the Proxy Check tool on Toolforge. The tool can tell you if the given IP address is a proxy or not.", diff --git a/public/build/app.7692d209.css b/public/build/app.ed7ba741.css similarity index 61% rename from public/build/app.7692d209.css rename to public/build/app.ed7ba741.css index 7f9ddb33b..894520d81 100644 --- a/public/build/app.7692d209.css +++ b/public/build/app.ed7ba741.css @@ -1 +1 @@ -@charset "UTF-8";.contributions-nav{margin-bottom:10px}.contributions-nav:last-child{margin-bottom:0}.contributions-limit--wrapper{-webkit-transform:translateY(-5px);-moz-transform:translateY(-5px);-ms-transform:translateY(-5px);-o-transform:translateY(-5px);transform:translateY(-5px)}.contributions-limit--wrapper label{font-weight:400}.contributions-loading{display:none;float:left}.contributions-container--loading{opacity:.3}.contributions-container--loading .contributions-nav{visibility:hidden}.contributions-container--loading .contributions-loading{display:block}html{height:100%}body{background:url(/build/images/gradient.bc9a6010.png);min-height:100%;position:relative}body:after{content:"";display:block;min-height:148px}body.rtl{direction:rtl}body.rtl .tool-links{float:right;padding-right:0!important}body.rtl .tool-links .nav{padding-right:0}body.rtl .tool-links .nav li{float:right}body.rtl .lang-group ul{left:0;right:auto;text-align:right}body.rtl .nav-buttons{float:left!important;margin-left:8px;margin-right:0}body.rtl .navbar-toggle{float:right!important}body.rtl .back-to-search{left:auto;right:0}body.rtl .back-to-search .glyphicon-chevron-left{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}@media (max-width:767px){body.rtl .tool-links li{float:none!important}}body.rtl .form-row:first-child>:first-child{border-top-left-radius:0;border-top-right-radius:4px}body.rtl .form-row:first-child>:last-child{border-top-left-radius:4px;border-top-right-radius:0}body.rtl .input-group-addon:first-child{border-left:0;border-right:1px solid #ccc}body.rtl .form-row:last-child>:first-child{border-bottom-left-radius:0;border-bottom-right-radius:4px}body.rtl .form-row:last-child>:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:0}body.rtl .input-group-addon:last-child{border-left:1px solid #ccc;border-right:0}body.rtl .form-label{text-align:right}body.rtl .form-label .tooltipcss{float:left!important}body.rtl .tooltip-body{margin-left:0;margin-right:28px;text-align:right}body.rtl .tooltip-body:after,body.rtl .tooltip-body:before{left:100%;right:auto}body.rtl .tooltip-body:before{border-color:transparent transparent transparent #dca}body.rtl .tooltip-body:after{border-color:transparent transparent transparent #fffaf0}body.rtl .typeahead{text-align:right}body.rtl .app-footer .footer-about{padding-right:15px}body.rtl .app-footer .footer-branding{float:left!important}body.rtl .table td,body.rtl .table th{text-align:right}@media (min-width:1200px){body.rtl .stat-list{float:right!important}}body.rtl .stat-list>.table td:first-child,body.rtl .stat-list>.table th:first-child{text-align:left}body.rtl .stat-list>.table td:last-child,body.rtl .stat-list>.table th:last-child{text-align:right}@media (max-width:767px){body.rtl .stat-list>.table td:first-child{text-align:right}}body.rtl .panel{text-align:right}body.rtl .toggle-table{float:right}body.rtl .toggle-table--chart{float:right;margin-left:0!important;margin-right:100px!important}body.rtl .chart-legend{margin-left:0;margin-right:15px}body.rtl .chart-legend .color-icon{margin-left:5px;margin-right:0}body.rtl .chart-wrapper{float:right;margin-left:50px;margin-right:0}body.rtl .xt-panel-description{float:left!important}body.rtl .dropdown-menu-right{left:0;right:auto;text-align:right}body.rtl .times-in-utc{float:right}body.rtl.pageinfo .legend-body{text-align:right}body.rtl.pageinfo .sort-entry--assessment,body.rtl.pageinfo .sort-entry--importance{text-align:center}body.rtl.pageinfo .date-range{left:30px;right:auto}body.rtl .rm-inline-margin{margin-left:-4px;margin-right:0}body.rtl .diff-pos:before{display:none}body.rtl .diff-pos:after{color:#006400;content:"+"}body.rtl .side-to-side>div{margin:0 0 15px 10px}.strong{font-weight:700}#wrapper{background:#fff;border-radius:5px;margin:15px}.site-notice{margin:0 15px}.navbar-top{border-top:0;min-height:51px;padding:0}.navbar-top li:last-child{padding-right:0}.tool-links{float:left;padding:0}.tool-links.in{position:relative;top:8px}.tool-links .nav{margin:0}.nav-buttons{left:1px;margin-right:8px;position:relative;top:8px}.nav-buttons li{padding:0 2px}.navbar-nav>li>a{height:50px;line-height:50px;padding:0 10px}.home-link{bottom:1px;padding:0 12px;position:relative}.home-link:after{background:#e7e7e7;bottom:0;content:"";display:inline-block;height:80%;left:100%;margin:auto;position:absolute;top:3px;width:1px}.home-link img{height:32px}.navbar-toggle{left:5px;margin:0;position:relative;top:9px}.lang-group .dropdown-menu{max-height:194px;overflow-y:scroll}.lang-group .btn{padding-left:30px}.lang-group svg{height:17px;left:8px;position:absolute;top:8px;width:17px}.login-btn .glyphicon-user{padding-right:2px;top:2px}.xt-page-title>small:before{content:"•";margin-right:10px}.tooltipcss{position:static;text-decoration:none}.tooltipcss .tooltip-body{background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:5px 5px 8px #ccc;color:#111;display:none;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;line-height:16px;margin-left:28px;max-width:350px;min-width:200px;padding:14px 20px;text-align:left;white-space:normal;z-index:10}.tooltip-body:after,.tooltip-body:before,.tooltipcss .tooltip-body{position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.tooltip-body:after,.tooltip-body:before{border-style:solid;color:transparent;content:"";display:block;height:0;right:100%;width:0}.tooltip-body:before{border-color:transparent #ddd transparent transparent;border-width:11px}.tooltip-body:after{border-color:transparent #fff transparent transparent;border-width:10px}.tooltipcss:hover .tooltip-body{display:block}.callout{border:0;left:-12px;position:absolute;top:30px;z-index:20}.form-label{min-width:15em;text-align:left;white-space:normal}.xt-heading-top{font-size:1.8em;margin-bottom:0;padding-bottom:0;position:relative}.xt-heading-subtitle{margin-top:3px}.back-to-search{font-size:16px;left:0;position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.back-to-search:hover{text-decoration:none}.date-range{font-size:70%;position:absolute;right:30px;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}@media (max-width:767px){.date-range{display:block;left:0;position:relative;top:0;transform:none}}.xt-toc{background:#fff;font-size:110%;margin-top:20px;padding:10px;width:100%;z-index:100}.xt-toc span:after{content:" • "}.xt-toc span:last-child:after{content:""}.xt-toc .bold{font-weight:700}.xt-toc.fixed{box-shadow:5px 5px 8px #ccc;left:50%;margin:0;position:fixed;top:0;-webkit-transform:translateX(-50%);-moz-transform:translateX(-50%);-ms-transform:translateX(-50%);-o-transform:translateX(-50%);transform:translateX(-50%);white-space:nowrap;width:auto}.table-sticky-header{position:relative}.table-sticky-header .sticky-heading{background:#fff;left:0;position:absolute;top:0}.xt-alert{margin:0;padding:10px;text-align:center}.xt-alert:not(:first-child){margin-top:10px}.xt-alert .close{opacity:.8;right:0;top:0}.xt-error-alert{margin-top:20px}.xt-error-alert:only-child{margin:0}.panel-primary>.panel-heading a{color:#fff}section>.panel-body{overflow-x:auto}.panel-body .alert:last-child,.panel-body .table:only-child{margin-bottom:0}.xt-panel-body table{clear:both;white-space:nowrap}.xt-panel-body .panel-body{width:100%}.xt-table>tbody>tr>td{padding-bottom:1px;padding-top:1px;vertical-align:top}.xt-table>tbody>tr>th{font-weight:400;padding:5px 2px;white-space:nowrap}.xt-show{display:none}.xt-hide,.xt-show{bottom:2px;cursor:pointer;position:relative}.xt-hide:hover,.xt-show:hover{text-decoration:underline}.xt-panel-description{font-size:80%;font-weight:400;line-height:normal;margin-left:8px}.inline-block{display:inline-block}.table .show-more-row td{border:0}.xt-pagination{margin:0}.panel-danger,.panel-default,.panel-primary{margin-bottom:0}.panel-default{margin-top:20px;text-align:left}.panel-default>.panel-heading{padding:5px 10px}.login{background-image:linear-gradient(transparent,transparent),url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTIiIGhlaWdodD0iMTMuODM3Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImUiPjxzdG9wIG9mZnNldD0iMCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1vcGFjaXR5PSIwIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImIiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNiNzRiYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzJkNTk5MCIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNjOWM5YzkiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9IjAiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjRkOWIxIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZGY5NzI1Ii8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeDE9IjMwLjkzNiIgeTE9IjI5LjU1MyIgeDI9IjMwLjkzNiIgeTI9IjM1LjgwMyIgaWQ9ImgiIHhsaW5rOmhyZWY9IiNjIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIvPjxsaW5lYXJHcmFkaWVudCB4MT0iMjAuNjYyIiB5MT0iMzUuODE4IiB4Mj0iMjIuNjI3IiB5Mj0iMzYuMjE4IiBpZD0iayIgeGxpbms6aHJlZj0iI2UiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0icm90YXRlKDEwLjQ5IDE3LjU2MSAzMi42MykiLz48bGluZWFyR3JhZGllbnQgeDE9IjIyLjY4NyIgeTE9IjM2LjM5IiB4Mj0iMjEuNDA4IiB5Mj0iMzUuNzQiIGlkPSJsIiB4bGluazpocmVmPSIjZSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLS45NzggLjIxIC4yMSAuOTc4IDU1LjExIC0zLjk0NSkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJmIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI4LjA5IiBjeT0iMjcuMjAzIiByPSIxMy41NjUiIGZ4PSIyOC4wOSIgZnk9IjI3LjIwMyIgaWQ9ImciIHhsaW5rOmhyZWY9IiNiIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjI5OCAwIDAgLjg4NSAtOC4zNTkgNC45NCkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJpIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI5LjM0NSIgY3k9IjE3LjA2NCIgcj0iOS4xNjIiIGZ4PSIyOS4zNDUiIGZ5PSIxNy4wNjQiIGlkPSJqIiB4bGluazpocmVmPSIjZCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjc4OCAwIDAgLjc4OCA2LjIyMSAzLjYxOCkiLz48L2RlZnM+PGcgY29sb3I9IiMwMDAiPjxwYXRoIGQ9Ik0zOS43NzUgMTkuMDA5YTguNjYyIDguNjYyIDAgMSAxLTE3LjMyNCAwIDguNjYyIDguNjYyIDAgMSAxIDE3LjMyNCAweiIgdHJhbnNmb3JtPSJtYXRyaXgoLjY5MyAwIDAgLjM3NCAtMTUuNTQ4IDMuNDgxKSIgZmlsbD0idXJsKCNmKSIgZmlsbC1ydWxlPSJldmVub2RkIiBvdmVyZmxvdz0idmlzaWJsZSIvPjxwYXRoIGQ9Ik00LjA0NiAxMi4zOThoNC4xMzdjMS4xNzIgMCAyLjMzMi0uNDMgMi43NTgtMS42NTUuNDA0LTEuMTYzLjA2OS0zLjM3OC0yLjU1MS01LjE3MUgzLjQ5NUMuODc1IDcuMjI3LjU0OCA5LjQ4OSAxLjE1MSAxMC44MTJjLjYxNCAxLjM0NyAxLjY1NSAxLjU4NiAyLjg5NiAxLjU4NnoiIGZpbGw9InVybCgjZykiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMjA0YTg3IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBzdHJva2Utd2lkdGg9Ii4zOSIvPjxwYXRoIGQ9Ik00LjMyMSA2LjE5M2MxLjI0MSAxLjEwMyAxLjc5MyA1LjEwMiAxLjc5MyA1LjEwMnMuNTUyLTMuOTk5IDEuNTE3LTUuMTcxbC0zLjMwOS4wNjl6IiBmaWxsPSJ1cmwoI2gpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTUuMjEgNi42MDdzLS44MzkuNjQ4LS43NjcgMS40MjhjLS43OTYtLjcwMi0uODE5LTIuMDQ4LS44MTktMi4wNDhsMS41ODYuNjJ6IiBmaWxsPSIjNzI5ZmNmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0ibTQuMDE4IDExLjk5MiA0LjA5Mi0uMDA5YzEuMDI5IDAgMi4wNDktLjM3NyAyLjQyMi0xLjQ1My4zNTUtMS4wMjItLjAzNy0yLjk2Ny0yLjMzOC00LjU0MmwtNC40OTUtLjA5NUMxLjM5OCA3LjM0Ni45NTIgOS4zMzQgMS40OTEgMTAuNTljLjUzOCAxLjI1NiAxLjMyNCAxLjM5MyAyLjUyNiAxLjQwMXoiIG9wYWNpdHk9Ii4yMTUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuMzkiLz48cGF0aCBkPSJNNi45NDEgNi42MDdzLjgzOS42NDguNzY3IDEuNDI4Yy43OTYtLjcwMi44MTktMi4wNDguODE5LTIuMDQ4bC0xLjU4Ni42MnoiIGZpbGw9IiM3MjlmY2YiIGZpbGwtcnVsZT0iZXZlbm9kZCIgb3ZlcmZsb3c9InZpc2libGUiLz48cGF0aCBkPSJNMzkuNzc1IDE5LjAwOWE4LjY2MiA4LjY2MiAwIDEgMS0xNy4zMjQgMCA4LjY2MiA4LjY2MiAwIDEgMSAxNy4zMjQgMHoiIHRyYW5zZm9ybT0ibWF0cml4KC4zOSAwIDAgLjM5IC02LjEzOCAtMi40NzUpIiBmaWxsPSJ1cmwoI2kpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTM5Ljc3NSAxOS4wMDlhOC42NjIgOC42NjIgMCAxIDEtMTcuMzI0IDAgOC42NjIgOC42NjIgMCAxIDEgMTcuMzI0IDB6IiBmaWxsPSJ1cmwoI2opIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iI2MxN2QxMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgdHJhbnNmb3JtPSJtYXRyaXgoLjM5IDAgMCAuMzkgLTYuMDg5IC0zLjg0KSIvPjxwYXRoIGQ9Ik05LjAwNSAzLjU3MmEyLjk2MiAyLjk2MiAwIDEgMS01LjkyNSAwIDIuOTYyIDIuOTYyIDAgMSAxIDUuOTI1IDB6IiBvcGFjaXR5PSIuMTk2IiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iLjM4OTg4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTIuNDMzIDEyLjA2MmMtLjQ4Ny0uMjEzLS43MDQtLjcyNS0uNzA0LS43MjVDMi4wNTcgOS43NSAzLjE4IDguNTg5IDMuMTggOC41ODlzLS44ODkgMi41LS43NDYgMy40NzN6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2spIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTkuODA2IDExLjcyOGMuNDgtLjIyNy43MDQtLjc4MS43MDQtLjc4MS0uMzc0LTEuNTc3LTEuNTUxLTIuNjY5LTEuNTUxLTIuNjY5cy45NjEgMi40NzQuODQ3IDMuNDV6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2wpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PC9nPjwvc3ZnPg==);background-position:0;background-repeat:no-repeat;line-height:1.125em;margin-top:.5em;padding-left:15px!important;white-space:nowrap}.app-footer{bottom:0;left:0;margin-top:30px;min-height:148px;padding:15px;position:absolute;width:100%}.app-footer hr{margin-bottom:0}.footer-content{padding-top:20px}.footer-about{display:inline;max-width:calc(100% - 200px)}.footer-branding,.footer-quote{white-space:nowrap}.footer-quote{display:inline-block;font-style:italic;max-width:100%;overflow-x:hidden;position:relative;text-overflow:ellipsis;top:8px}.lang-dropdown .dropdown-menu{height:194px;overflow-y:scroll}.navbar-default{background:transparent}.form-fieldset{margin-bottom:15px}.form-fieldset .checkbox{margin-bottom:0}.form-fieldset:nth-child(2){margin-top:-15px}.form-submit{margin-bottom:20px}.form-row>*{border-radius:0}.form-row .input-group-addon label{font-weight:400;margin:0}.form-row:first-child>:first-child{border-top-left-radius:4px}.form-row:first-child>:last-child{border-top-right-radius:4px}.form-row:last-child>:first-child{border-bottom-left-radius:4px}.form-row:last-child>.form-control:last-of-type{border-bottom-right-radius:4px}.form-control[disabled],.form-control[readonly]{background-color:transparent;cursor:not-allowed;opacity:.8}.stat-list{margin-bottom:20px}.stat-list caption{text-align:center}.panel-body .stat-list:only-child{margin-bottom:0}.stat-list>.table td,.stat-list>.table th{border:0;padding-bottom:0;padding-top:0;white-space:normal}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer),.stat-list>.table th:first-child:not(.non-label,.stat-list--footer){font-weight:700;text-align:right}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer):after,.stat-list>.table th:first-child:not(.non-label,.stat-list--footer):after{content:":"}.stat-list--new-group{padding-top:10px!important}.stat-list tr:first-child>td.stat-list--new-group{padding-top:0!important}.stat-list--group{border-top:0!important}.stat-list--group>tr:first-child>td{border-bottom:1px solid #eee!important;padding-top:15px!important;text-align:center!important}.stat-list--group>tr:nth-child(2)>td{padding-top:5px!important}.color-icon{border-radius:100%;display:inline-block;height:15px;vertical-align:-2px;width:15px}.diff-pos{color:#006400}.diff-pos:before{color:#006400;content:"+"}.diff-neg{color:#8b0000}.diff-zero{color:#a2a9b1}.sort-link{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.sort-link .glyphicon{top:2px;visibility:hidden}.sort-link .glyphicon-sort-by-alphabet,.sort-link .glyphicon-sort-by-alphabet-alt,.sort-link:hover .glyphicon-sort{visibility:visible}.assessment-badge{height:20px;width:20px}.rm-inline-margin{margin-right:-4px}.rm-inline-margin-left{margin-left:-4px}.toggle-table{float:left}.toggle-table .toggle-table--toggle{cursor:pointer;margin-right:4px;position:relative}.toggle-table tr:hover .toggle-table--toggle .color-icon{visibility:hidden}.toggle-table tr:hover .toggle-table--toggle .glyphicon{display:block}.toggle-table tr.excluded td.linked a,.toggle-table tr.excluded td:not(.linked){opacity:.5;text-decoration:line-through}.toggle-table tr.excluded .color-icon{opacity:.5}.toggle-table td .glyphicon{display:none;left:1px;position:absolute}.toggle-table--chart{float:left;margin-left:100px;margin-top:20px;max-width:500px}.toggle-table--chart canvas{height:400px;width:400px}.basic-info-charts{clear:both;display:block;padding-top:20px;position:relative}.basic-info-charts canvas:not(#sizechart-canvas){max-width:150px}.basic-info-charts .sizechart-container{height:200px;min-width:650px;position:relative}.basic-info-charts .chart-wrapper{display:flex;float:left;margin-right:50px}.basic-info-charts .chart-legend{align-self:center;margin-left:15px}.basic-info-charts .chart-legend .color-icon{vertical-align:-4px}.display-title *{font-size:inherit!important}.error-wrapper{font-size:18px}.error-wrapper p{margin-bottom:30px}.error-mascot{margin-right:30px;max-width:100%;width:300px}.times-in-utc{margin-top:15px}.download-dropdown{bottom:5px}.download-dropdown .glyphicon-download-alt{top:2px}.multi-select{height:auto}.multi-select .checkbox{display:inline-block;float:left;margin-top:3px;width:33.3333333333%}.user-group-icon{height:18px}a.help-icon{font-size:25px;position:relative;text-decoration:none;top:5px}.help-text{cursor:help;text-decoration:underline dotted}.reverted-edit{background:#fcf8e3!important}.side-to-side>div{display:inline-block;margin:0 10px 15px 0;vertical-align:top}.side-to-side{clear:both}.link-loading{font-style:italic;pointer-events:none}.pageinfo .top-editors-charts{display:block;margin-bottom:25px;position:relative}.pageinfo .top-editors-charts canvas{max-height:250px;max-width:250px}.pageinfo .year-count-charts{clear:both;display:block;margin-bottom:25px;position:relative}.pageinfo .year-count-charts canvas{max-width:800px}.pageinfo .chart-wrapper{display:flex;flex-wrap:wrap;float:left;margin-right:50px}.pageinfo #textshares_chart{display:none;height:auto;width:auto}.pageinfo .chart-title{font-weight:700;text-align:center}.pageinfo .chart-legend{align-self:center;margin-left:15px}.pageinfo .chart-legend .color-icon{vertical-align:-4px}.pageinfo .legend-label{display:inline-block;max-width:200px;overflow-x:hidden;text-overflow:ellipsis;vertical-align:middle}.pageinfo .legend-value{vertical-align:bottom}.pageinfo .top-editors-table{margin-bottom:0}.pageinfo .footnotes{margin-bottom:20px;margin-top:5px}.pageinfo .month-counts-table .stripes-column{height:30px;padding:0}.pageinfo .month-counts-table .stripes{height:33.3333333333%}.pageinfo .sort-entry--assessment,.pageinfo .sort-entry--importance{font-weight:700;text-align:center}.pageinfo .sort-entry--assessment a,.pageinfo .sort-entry--importance a{color:#4365cc}.pageinfo .bugs-table .sort-entry--explanation{min-width:400px;white-space:pre-wrap}@media (max-width:767px){.pageinfo #year_count_legend{margin:0}.pageinfo #year_count_legend .legend-body>div{display:inline;margin-right:10px}}.autoedits #summary .chart-wrapper,.autoedits #summary .chart-wrapper canvas{max-width:300px}.autoedits #summary .chart-wrapper .chart-legend{display:none}.autoedits #tool_chart{display:none;height:auto;width:auto}.autoedits .autoedits-stat-list{margin-bottom:0;padding-bottom:0}.autoedits .tool-selector-form{margin-bottom:20px}.autoedits .tool-selector-form .btn-primary{vertical-align:bottom}.autoedits .tool-selector-form .form-submit{margin-bottom:0}.autoedits .contributions-container--loading .tool-selector-form{visibility:hidden}.autoedits #toolSelector{display:inline-block;width:auto}.autoedits .tooltipcss--tool-counts{cursor:help;position:relative;vertical-align:-1px}.autoedits .tooltipcss--tool-counts .tooltip-body{min-width:300px}.autoedits .footnotes{margin:20px 0 0}.blame .diff{border:0;border-collapse:initial;border-spacing:4px;margin:0;width:100%}.blame .diff td{padding:.33em .5em}.blame .diff td div{word-wrap:break-word}.blame .diff-lineno{font-weight:700}.blame .diff-marker{font-size:1.25em;padding:.25em;text-align:right}.blame .diff-context{background:#f8f9fa;border-color:#eaecf0;color:#222}.blame .diff-deletedline{border-color:#ffe49c}.blame .diff-deletedline .diffchange{background:#feeec8}.blame .diff-addedline{border-color:#a3d3ff}.blame .diff-addedline .diffchange{background:#d8ecff}.blame .diff-addedline,.blame .diff-context,.blame .diff-deletedline{border-radius:.33em;border-style:solid;border-width:1px 1px 1px 4px;line-height:1.6;vertical-align:top;white-space:pre-wrap}.blame .diff-addedline .diffchange,.blame .diff-deletedline .diffchange{border-radius:.33em;padding:.25em 0}.blame .diffchange{text-decoration:none}.categoryedits .toggle-table--chart{margin-left:50px}.categoryedits #category_chart{display:none;height:auto;width:auto}.categoryedits .select2-selection--multiple{border-color:#ccc!important;border-radius:0}.categoryedits .select2-results__option,.categoryedits .select2-selection__rendered{padding-left:12px!important}.categoryedits .select2-results__message{display:none}.categoryedits .select2-results__option--highlighted{background:#3379b7!important}.categoryedits .loading-results{display:none}.editcounter .stat-list .table{margin:0 auto}.editcounter .stat-list .table.rights-changes-summary{margin:0}.editcounter .stat-list--empty-group{font-weight:400!important}.editcounter .stat-list--empty-group:after{content:""!important}.editcounter #timecard-bubble-chart{max-width:1100px}.editcounter #monthcounts-canvas,.editcounter #yearcounts-canvas{max-width:1000px}.editcounter .yearmonth-container{display:flex;flex-direction:row;flex-wrap:wrap;width:100%}.editcounter .yearmonth-chart-container{flex-grow:1}.editcounter #namespace-canvas{height:auto;width:auto}.editcounter .top-project-edit-counts td{white-space:nowrap}.editcounter .footnotes{clear:both;padding-top:20px}.editcounter .tooltipcss--pages-created{position:relative;vertical-align:-1px}.editcounter .tooltipcss--pages-created:hover{text-decoration:none}.editcounter .tooltipcss--pages-created .tooltip-body{min-width:300px}.editcounter .pending{opacity:.5}.home #wrapper{background:transparent}.home .splash-logo{margin-top:30px;max-width:320px;width:100%}.home .tool-list{padding-top:10px;text-align:left}.home .tool-list .btn{display:block;margin-top:15px;position:relative;text-align:left;white-space:normal;width:100%}.home .tool-name{font-weight:700}@media (max-width:767px){.home .splash-logo{max-width:450px;width:90%}}.meta #api_usage_chart,.meta #usage_chart{max-width:1000px}.pages .panel-body h3{clear:both}.pages .panel-body h4:not(:first-child){padding-top:10px}.pages .deleted-page{cursor:help;position:relative}.pages .deleted-page .tooltip-body{left:67%;width:300px}.pages .sort-entry--page-title{white-space:normal}.topedits .color-icon{margin-right:3px}.topedits .footnotes{margin:20px 0}@media (max-width:767px){.basic-info-charts{display:none;margin-bottom:0}}@media (prefers-color-scheme:dark){:root{scrollbar-color:#aaa transparent}#wrapper,.panel-body,.xt-toc,body{background:#181818;color:#aaa}.xt-toc.fixed{box-shadow:5px 5px 8px #333}.home-link:after,[role=separator]{display:none}.dropdown-menu,.dropdown.open,.form-label{background:#222}.dropdown-menu{box-shadow:0 6px 12px #333}.dropdown-menu>.active>a,.dropdown-menu>li>a{background:#222;color:#999}.dropdown-menu>.active>a:hover,.dropdown-menu>li>a:hover{background:#333;color:#999}.navbar-default{border:0}.navbar-default .navbar-nav>li>a{color:#999}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#aaa}header{background:#212121!important}.stat-list--group>tr:first-child>td{border-color:#606060!important}.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-color:#606060}.table-striped>tbody>tr:hover,.table-striped>tbody>tr:nth-of-type(odd){background-color:transparent}.table-sticky-header .sticky-heading{background:#111}a,a:hover{color:#09f}.panel-primary,.panel-primary .panel-heading{border-color:#606060}.help-icon,.help-icon:hover,.panel-primary>.panel-heading,.panel-primary>.panel-heading a,.panel-primary>.panel-heading a:hover,h4{color:#aaa}.panel{background-color:transparent}.form-control,.form-label,.panel-default,.panel-default>.panel-heading,hr{border-color:#333}.form-control:focus{border-color:#606060;box-shadow:inset 0 1px 1px #333,0 0 8px #333}.form-control,.form-label,label{color:#999}.form-control{background:#111;color:#999}.blame .diff-context{background:transparent;color:#aaa}.diffchange-inline{color:#333}.btn-default{background:#222;border-color:#222;color:#999}.btn-default svg{fill:#999}.btn-default:hover svg,.open .btn-default svg{fill:#333}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--multiple .select2-selection__choice{background:transparent}.select2-dropdown{background-color:#222}.categoryedits .select2-selection--multiple{border-color:#333!important}.categoryedits .select2-results__option--highlighted,.select2-container--default .select2-results__option[aria-selected=true]{background-color:#333!important}.alert .close{opacity:.8;text-shadow:none}.alert-info{background:#012;border:0;color:#999}.alert-warning{background-color:#8a6d3b;color:#fcf8e3}.alert-danger{background-color:#a94442;border-color:#ff6b6b;color:#1b1818}.color-icon,canvas{background:transparent}.tooltipcss .tooltip-body{background:#333;border-color:#606060;box-shadow:5px 5px 8px #333;color:#aaa}.tooltipcss .tooltip-body:before{border-color:transparent #606060 transparent transparent}.tooltipcss .tooltip-body:after{border-color:transparent #333 transparent transparent}body.rtl .tooltipcss .tooltip-body:before{border-color:transparent transparent transparent #606060}body.rtl .tooltipcss .tooltip-body:after{border-color:transparent transparent transparent #333}.diff-pos,.diff-pos:before{color:#00b400}.diff-neg{color:#f73d3d}.pagination>li>a{background-color:transparent;border-color:#606060;color:#aaa}.pagination>li>a:focus,.pagination>li>a:hover{background-color:#333;border-color:#606060;color:#aaa}.pagination>li.disabled>span{background-color:inherit;border-color:inherit;opacity:.5}.pagination>li.active>a,.pagination>li.active>a:focus,.pagination>li.active>a:hover{background-color:#333;border-color:#606060;color:#aaa}.reverted-edit{background:#4e4c40!important}.reverted-edit .text-info{color:#74cefa}.reverted-edit .text-muted{color:#909090}.footer-branding,.home-link,.splash-logo{filter:invert(1) hue-rotate(180deg)}}@media (min-width:1200px){.xt-panel-body table{width:auto}}@media (max-width:1200px){.stat-list>.table td:first-child,.stat-list>.table th:first-child{width:300px}}@media (max-width:992px){.error-mascot{width:200px}}@media (max-width:767px){.basic-info-charts .chart-wrapper{display:block!important}.basic-info-charts .chart-wrapper .chart-legend{margin-bottom:15px}.app-footer{position:relative}.footer-branding{float:left!important;margin-bottom:10px}.footer-about{clear:both;display:block;max-width:100%}.footer-quote{overflow-x:initial;text-overflow:clip;white-space:normal}.xt-panel-description{display:block}.stat-list{padding:0}.stat-list>.table td:first-child,.stat-list>.table th:first-child{display:block;padding-top:5px;text-align:left!important}.stat-list>.table td:first-child.stat-list--new-group,.stat-list>.table th:first-child.stat-list--new-group{padding-top:15px!important}.stat-list>.table td .stat-list--new-group,.stat-list>.table td:last-child,.stat-list>.table th .stat-list--new-group,.stat-list>.table th:last-child{display:block;padding-top:0!important}.stat-list--group{padding-top:15px!important}.stat-list--group tr>td{padding-top:5px!important}.stat-list--group>tr:first-child>td{text-align:left!important}.back-to-search{font-size:0}.back-to-search .glyphicon{font-size:medium}.tool-links{clear:both;width:100%}.xt-panel-body{padding:15px 0}.panel-default{border-radius:0}#wrapper{margin:15px 5px}.xt-toc{display:none}.toggle-table--chart{margin:0;max-width:100%}.xt-page-title>small{display:block;padding:3px 0 10px}.xt-page-title>small:before{display:none}.input-group{margin-bottom:15px;width:100%}.input-group .tooltipcss{display:none}.input-group input[type=text]{border-radius:0!important}.input-group-addon{background:transparent;border:0;display:block;padding:0}.input-group-addon:first-child{font-weight:700}.input-group-addon:last-child{clear:both;padding-top:5px}.error-wrapper{padding:10px}.error-mascot--wrapper{margin-bottom:20px;text-align:center;width:100%}.times-in-utc{padding:0 15px}} \ No newline at end of file +@charset "UTF-8";.contributions-nav{margin-bottom:10px}.contributions-nav:last-child{margin-bottom:0}.contributions-limit--wrapper{-webkit-transform:translateY(-5px);-moz-transform:translateY(-5px);-ms-transform:translateY(-5px);-o-transform:translateY(-5px);transform:translateY(-5px)}.contributions-limit--wrapper label{font-weight:400}.contributions-loading{display:none;float:left}.contributions-container--loading{opacity:.3}.contributions-container--loading .contributions-nav{visibility:hidden}.contributions-container--loading .contributions-loading{display:block}html{height:100%}body{background:url(/build/images/gradient.bc9a6010.png);min-height:100%;position:relative}body:after{content:"";display:block;min-height:148px}body.rtl{direction:rtl}body.rtl .tool-links{float:right;padding-right:0!important}body.rtl .tool-links .nav{padding-right:0}body.rtl .tool-links .nav li{float:right}body.rtl .lang-group ul{left:0;right:auto;text-align:right}body.rtl .nav-buttons{float:left!important;margin-left:8px;margin-right:0}body.rtl .navbar-toggle{float:right!important}body.rtl .back-to-search{left:auto;right:0}body.rtl .back-to-search .glyphicon-chevron-left{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}@media (max-width:767px){body.rtl .tool-links li{float:none!important}}body.rtl .form-row:first-child>:first-child{border-top-left-radius:0;border-top-right-radius:4px}body.rtl .form-row:first-child>:last-child{border-top-left-radius:4px;border-top-right-radius:0}body.rtl .input-group-addon:first-child{border-left:0;border-right:1px solid #ccc}body.rtl .form-row:last-child>:first-child{border-bottom-left-radius:0;border-bottom-right-radius:4px}body.rtl .form-row:last-child>:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:0}body.rtl .input-group-addon:last-child{border-left:1px solid #ccc;border-right:0}body.rtl .form-label{text-align:right}body.rtl .form-label .tooltipcss{float:left!important}body.rtl .tooltip-body{margin-left:0;margin-right:28px;text-align:right}body.rtl .tooltip-body:after,body.rtl .tooltip-body:before{left:100%;right:auto}body.rtl .tooltip-body:before{border-color:transparent transparent transparent #dca}body.rtl .tooltip-body:after{border-color:transparent transparent transparent #fffaf0}body.rtl .typeahead{text-align:right}body.rtl .app-footer .footer-about{padding-right:15px}body.rtl .app-footer .footer-branding{float:left!important}body.rtl .table td,body.rtl .table th{text-align:right}@media (min-width:1200px){body.rtl .stat-list{float:right!important}}body.rtl .stat-list>.table td:first-child,body.rtl .stat-list>.table th:first-child{text-align:left}body.rtl .stat-list>.table td:last-child,body.rtl .stat-list>.table th:last-child{text-align:right}@media (max-width:767px){body.rtl .stat-list>.table td:first-child{text-align:right}}body.rtl .panel{text-align:right}body.rtl .toggle-table{float:right}body.rtl .toggle-table--chart{float:right;margin-left:0!important;margin-right:100px!important}body.rtl .chart-legend{margin-left:0;margin-right:15px}body.rtl .chart-legend .color-icon{margin-left:5px;margin-right:0}body.rtl .chart-wrapper{float:right;margin-left:50px;margin-right:0}body.rtl .xt-panel-description{float:left!important}body.rtl .dropdown-menu-right{left:0;right:auto;text-align:right}body.rtl .times-in-utc{float:right}body.rtl.pageinfo .legend-body{text-align:right}body.rtl.pageinfo .sort-entry--assessment,body.rtl.pageinfo .sort-entry--importance{text-align:center}body.rtl.pageinfo .date-range{left:30px;right:auto}body.rtl .rm-inline-margin{margin-left:-4px;margin-right:0}body.rtl .diff-pos:before{display:none}body.rtl .diff-pos:after{color:#006400;content:"+"}body.rtl .side-to-side>div{margin:0 0 15px 10px}.strong{font-weight:700}#wrapper{background:#fff;border-radius:5px;margin:15px}.site-notice{margin:0 15px}.navbar-top{border-top:0;min-height:51px;padding:0}.navbar-top li:last-child{padding-right:0}.tool-links{float:left;padding:0}.tool-links.in{position:relative;top:8px}.tool-links .nav{margin:0}.nav-buttons{left:1px;margin-right:8px;position:relative;top:8px}.nav-buttons li{padding:0 2px}.navbar-nav>li>a{height:50px;line-height:50px;padding:0 10px}.home-link{bottom:1px;padding:0 12px;position:relative}.home-link:after{background:#e7e7e7;bottom:0;content:"";display:inline-block;height:80%;left:100%;margin:auto;position:absolute;top:3px;width:1px}.home-link img{height:32px}.navbar-toggle{left:5px;margin:0;position:relative;top:9px}.lang-group .dropdown-menu{max-height:194px;overflow-y:scroll}.lang-group .btn{padding-left:30px}.lang-group svg{height:17px;left:8px;position:absolute;top:8px;width:17px}.login-btn .glyphicon-user{padding-right:2px;top:2px}.xt-page-title>small:before{content:"•";margin-right:10px}.tooltipcss{position:static;text-decoration:none}.tooltipcss .tooltip-body{background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:5px 5px 8px #ccc;color:#111;display:none;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;line-height:16px;margin-left:28px;max-width:350px;min-width:200px;padding:14px 20px;text-align:left;white-space:normal;z-index:10}.tooltip-body:after,.tooltip-body:before,.tooltipcss .tooltip-body{position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.tooltip-body:after,.tooltip-body:before{border-style:solid;color:transparent;content:"";display:block;height:0;right:100%;width:0}.tooltip-body:before{border-color:transparent #ddd transparent transparent;border-width:11px}.tooltip-body:after{border-color:transparent #fff transparent transparent;border-width:10px}.tooltipcss:hover .tooltip-body{display:block}.callout{border:0;left:-12px;position:absolute;top:30px;z-index:20}.form-label{min-width:15em;text-align:left;white-space:normal}.xt-heading-top{font-size:1.8em;margin-bottom:0;padding-bottom:0;position:relative}.xt-heading-subtitle{margin-top:3px}.back-to-search{font-size:16px;left:0;position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.back-to-search:hover{text-decoration:none}.date-range{font-size:70%;position:absolute;right:30px;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}@media (max-width:767px){.date-range{display:block;left:0;position:relative;top:0;transform:none}}.xt-toc{background:#fff;font-size:110%;margin-top:20px;padding:10px;width:100%;z-index:100}.xt-toc span:after{content:" • "}.xt-toc span:last-child:after{content:""}.xt-toc .bold{font-weight:700}.xt-toc.fixed{box-shadow:5px 5px 8px #ccc;left:50%;margin:0;position:fixed;top:0;-webkit-transform:translateX(-50%);-moz-transform:translateX(-50%);-ms-transform:translateX(-50%);-o-transform:translateX(-50%);transform:translateX(-50%);white-space:nowrap;width:auto}.table-sticky-header{position:relative}.table-sticky-header .sticky-heading{background:#fff;left:0;position:absolute;top:0}.xt-alert{margin:0;padding:10px;text-align:center}.xt-alert:not(:first-child){margin-top:10px}.xt-alert .close{opacity:.8;right:0;top:0}.xt-error-alert{margin-top:20px}.xt-error-alert:only-child{margin:0}.panel-primary>.panel-heading a{color:#fff}section>.panel-body{overflow-x:auto}.panel-body .alert:last-child,.panel-body .table:only-child{margin-bottom:0}.xt-panel-body table{clear:both;white-space:nowrap}.xt-panel-body .panel-body{width:100%}.xt-table>tbody>tr>td{padding-bottom:1px;padding-top:1px;vertical-align:top}.xt-table>tbody>tr>th{font-weight:400;padding:5px 2px;white-space:nowrap}.xt-show{display:none}.xt-hide,.xt-show{bottom:2px;cursor:pointer;position:relative}.xt-hide:hover,.xt-show:hover{text-decoration:underline}.xt-panel-description{font-size:80%;font-weight:400;line-height:normal;margin-left:8px}.inline-block{display:inline-block}.table .show-more-row td{border:0}.xt-pagination{margin:0}.panel-danger,.panel-default,.panel-primary{margin-bottom:0}.panel-default{margin-top:20px;text-align:left}.panel-default>.panel-heading{padding:5px 10px}.login{background-image:linear-gradient(transparent,transparent),url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTIiIGhlaWdodD0iMTMuODM3Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImUiPjxzdG9wIG9mZnNldD0iMCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1vcGFjaXR5PSIwIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImIiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNiNzRiYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzJkNTk5MCIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNjOWM5YzkiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9IjAiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjRkOWIxIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZGY5NzI1Ii8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeDE9IjMwLjkzNiIgeTE9IjI5LjU1MyIgeDI9IjMwLjkzNiIgeTI9IjM1LjgwMyIgaWQ9ImgiIHhsaW5rOmhyZWY9IiNjIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIvPjxsaW5lYXJHcmFkaWVudCB4MT0iMjAuNjYyIiB5MT0iMzUuODE4IiB4Mj0iMjIuNjI3IiB5Mj0iMzYuMjE4IiBpZD0iayIgeGxpbms6aHJlZj0iI2UiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0icm90YXRlKDEwLjQ5IDE3LjU2MSAzMi42MykiLz48bGluZWFyR3JhZGllbnQgeDE9IjIyLjY4NyIgeTE9IjM2LjM5IiB4Mj0iMjEuNDA4IiB5Mj0iMzUuNzQiIGlkPSJsIiB4bGluazpocmVmPSIjZSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLS45NzggLjIxIC4yMSAuOTc4IDU1LjExIC0zLjk0NSkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJmIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI4LjA5IiBjeT0iMjcuMjAzIiByPSIxMy41NjUiIGZ4PSIyOC4wOSIgZnk9IjI3LjIwMyIgaWQ9ImciIHhsaW5rOmhyZWY9IiNiIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjI5OCAwIDAgLjg4NSAtOC4zNTkgNC45NCkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJpIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI5LjM0NSIgY3k9IjE3LjA2NCIgcj0iOS4xNjIiIGZ4PSIyOS4zNDUiIGZ5PSIxNy4wNjQiIGlkPSJqIiB4bGluazpocmVmPSIjZCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjc4OCAwIDAgLjc4OCA2LjIyMSAzLjYxOCkiLz48L2RlZnM+PGcgY29sb3I9IiMwMDAiPjxwYXRoIGQ9Ik0zOS43NzUgMTkuMDA5YTguNjYyIDguNjYyIDAgMSAxLTE3LjMyNCAwIDguNjYyIDguNjYyIDAgMSAxIDE3LjMyNCAweiIgdHJhbnNmb3JtPSJtYXRyaXgoLjY5MyAwIDAgLjM3NCAtMTUuNTQ4IDMuNDgxKSIgZmlsbD0idXJsKCNmKSIgZmlsbC1ydWxlPSJldmVub2RkIiBvdmVyZmxvdz0idmlzaWJsZSIvPjxwYXRoIGQ9Ik00LjA0NiAxMi4zOThoNC4xMzdjMS4xNzIgMCAyLjMzMi0uNDMgMi43NTgtMS42NTUuNDA0LTEuMTYzLjA2OS0zLjM3OC0yLjU1MS01LjE3MUgzLjQ5NUMuODc1IDcuMjI3LjU0OCA5LjQ4OSAxLjE1MSAxMC44MTJjLjYxNCAxLjM0NyAxLjY1NSAxLjU4NiAyLjg5NiAxLjU4NnoiIGZpbGw9InVybCgjZykiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMjA0YTg3IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBzdHJva2Utd2lkdGg9Ii4zOSIvPjxwYXRoIGQ9Ik00LjMyMSA2LjE5M2MxLjI0MSAxLjEwMyAxLjc5MyA1LjEwMiAxLjc5MyA1LjEwMnMuNTUyLTMuOTk5IDEuNTE3LTUuMTcxbC0zLjMwOS4wNjl6IiBmaWxsPSJ1cmwoI2gpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTUuMjEgNi42MDdzLS44MzkuNjQ4LS43NjcgMS40MjhjLS43OTYtLjcwMi0uODE5LTIuMDQ4LS44MTktMi4wNDhsMS41ODYuNjJ6IiBmaWxsPSIjNzI5ZmNmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0ibTQuMDE4IDExLjk5MiA0LjA5Mi0uMDA5YzEuMDI5IDAgMi4wNDktLjM3NyAyLjQyMi0xLjQ1My4zNTUtMS4wMjItLjAzNy0yLjk2Ny0yLjMzOC00LjU0MmwtNC40OTUtLjA5NUMxLjM5OCA3LjM0Ni45NTIgOS4zMzQgMS40OTEgMTAuNTljLjUzOCAxLjI1NiAxLjMyNCAxLjM5MyAyLjUyNiAxLjQwMXoiIG9wYWNpdHk9Ii4yMTUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuMzkiLz48cGF0aCBkPSJNNi45NDEgNi42MDdzLjgzOS42NDguNzY3IDEuNDI4Yy43OTYtLjcwMi44MTktMi4wNDguODE5LTIuMDQ4bC0xLjU4Ni42MnoiIGZpbGw9IiM3MjlmY2YiIGZpbGwtcnVsZT0iZXZlbm9kZCIgb3ZlcmZsb3c9InZpc2libGUiLz48cGF0aCBkPSJNMzkuNzc1IDE5LjAwOWE4LjY2MiA4LjY2MiAwIDEgMS0xNy4zMjQgMCA4LjY2MiA4LjY2MiAwIDEgMSAxNy4zMjQgMHoiIHRyYW5zZm9ybT0ibWF0cml4KC4zOSAwIDAgLjM5IC02LjEzOCAtMi40NzUpIiBmaWxsPSJ1cmwoI2kpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTM5Ljc3NSAxOS4wMDlhOC42NjIgOC42NjIgMCAxIDEtMTcuMzI0IDAgOC42NjIgOC42NjIgMCAxIDEgMTcuMzI0IDB6IiBmaWxsPSJ1cmwoI2opIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iI2MxN2QxMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgdHJhbnNmb3JtPSJtYXRyaXgoLjM5IDAgMCAuMzkgLTYuMDg5IC0zLjg0KSIvPjxwYXRoIGQ9Ik05LjAwNSAzLjU3MmEyLjk2MiAyLjk2MiAwIDEgMS01LjkyNSAwIDIuOTYyIDIuOTYyIDAgMSAxIDUuOTI1IDB6IiBvcGFjaXR5PSIuMTk2IiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iLjM4OTg4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTIuNDMzIDEyLjA2MmMtLjQ4Ny0uMjEzLS43MDQtLjcyNS0uNzA0LS43MjVDMi4wNTcgOS43NSAzLjE4IDguNTg5IDMuMTggOC41ODlzLS44ODkgMi41LS43NDYgMy40NzN6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2spIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTkuODA2IDExLjcyOGMuNDgtLjIyNy43MDQtLjc4MS43MDQtLjc4MS0uMzc0LTEuNTc3LTEuNTUxLTIuNjY5LTEuNTUxLTIuNjY5cy45NjEgMi40NzQuODQ3IDMuNDV6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2wpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PC9nPjwvc3ZnPg==);background-position:0;background-repeat:no-repeat;line-height:1.125em;margin-top:.5em;padding-left:15px!important;white-space:nowrap}.app-footer{bottom:0;left:0;margin-top:30px;min-height:148px;padding:15px;position:absolute;width:100%}.app-footer hr{margin-bottom:0}.footer-content{padding-top:20px}.footer-about{display:inline;max-width:calc(100% - 200px)}.footer-branding,.footer-quote{white-space:nowrap}.footer-quote{display:inline-block;font-style:italic;max-width:100%;overflow-x:hidden;position:relative;text-overflow:ellipsis;top:8px}.lang-dropdown .dropdown-menu{height:194px;overflow-y:scroll}.navbar-default{background:transparent}.form-fieldset{margin-bottom:15px}.form-fieldset .checkbox{margin-bottom:0}.form-fieldset:nth-child(2){margin-top:-15px}.form-submit{margin-bottom:20px}.form-row>*{border-radius:0}.form-row .input-group-addon label{font-weight:400;margin:0}.form-row:first-child>:first-child{border-top-left-radius:4px}.form-row:first-child>:last-child{border-top-right-radius:4px}.form-row:last-child>:first-child{border-bottom-left-radius:4px}.form-row:last-child>.form-control:last-of-type{border-bottom-right-radius:4px}.form-control[disabled],.form-control[readonly]{background-color:transparent;cursor:not-allowed;opacity:.8}.stat-list{margin-bottom:20px}.stat-list caption{text-align:center}.panel-body .stat-list:only-child{margin-bottom:0}.stat-list>.table td,.stat-list>.table th{border:0;padding-bottom:0;padding-top:0;white-space:normal}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer),.stat-list>.table th:first-child:not(.non-label,.stat-list--footer){font-weight:700;text-align:right}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer):after,.stat-list>.table th:first-child:not(.non-label,.stat-list--footer):after{content:":"}.stat-list--new-group{padding-top:10px!important}.stat-list tr:first-child>td.stat-list--new-group{padding-top:0!important}.stat-list--group{border-top:0!important}.stat-list--group>tr:first-child>td{border-bottom:1px solid #eee!important;padding-top:15px!important;text-align:center!important}.stat-list--group>tr:nth-child(2)>td{padding-top:5px!important}.color-icon{border-radius:100%;display:inline-block;height:15px;vertical-align:-2px;width:15px}.diff-pos{color:#006400}.diff-pos:before{color:#006400;content:"+"}.diff-neg{color:#8b0000}.diff-zero{color:#a2a9b1}.sort-link{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.sort-link .glyphicon{top:2px;visibility:hidden}.sort-link .glyphicon-sort-by-alphabet,.sort-link .glyphicon-sort-by-alphabet-alt,.sort-link:hover .glyphicon-sort{visibility:visible}.assessment-badge{height:20px;width:20px}.rm-inline-margin{margin-right:-4px}.rm-inline-margin-left{margin-left:-4px}.toggle-table{float:left}.toggle-table .toggle-table--toggle{cursor:pointer;margin-right:4px;position:relative}.toggle-table tr:hover .toggle-table--toggle .color-icon{visibility:hidden}.toggle-table tr:hover .toggle-table--toggle .glyphicon{display:block}.toggle-table tr.excluded td.linked a,.toggle-table tr.excluded td:not(.linked){opacity:.5;text-decoration:line-through}.toggle-table tr.excluded .color-icon{opacity:.5}.toggle-table td .glyphicon{display:none;left:1px;position:absolute}.toggle-table--chart{float:left;margin-left:100px;margin-top:20px;max-width:500px}.toggle-table--chart canvas{height:400px;width:400px}.basic-info-charts{clear:both;display:block;padding-top:20px;position:relative}.basic-info-charts canvas:not(#sizechart-canvas){max-width:150px}.basic-info-charts .sizechart-container{height:200px;min-width:650px;position:relative}.basic-info-charts .chart-wrapper{display:flex;float:left;margin-right:50px}.basic-info-charts .chart-legend{align-self:center;margin-left:15px}.basic-info-charts .chart-legend .color-icon{vertical-align:-4px}.display-title *{font-size:inherit!important}.error-wrapper{font-size:18px}.error-wrapper p{margin-bottom:30px}.error-mascot{margin-right:30px;max-width:100%;width:300px}.times-in-utc{margin-top:15px}.download-dropdown{bottom:5px}.download-dropdown .glyphicon-download-alt{top:2px}.multi-select{height:auto}.multi-select .checkbox{display:inline-block;float:left;margin-top:3px;width:33.3333333333%}.user-group-icon{height:18px}a.help-icon{font-size:25px;position:relative;text-decoration:none;top:5px}.help-text{cursor:help;text-decoration:underline dotted}.reverted-edit{background:#fcf8e3!important}.side-to-side>div{display:inline-block;margin:0 10px 15px 0;vertical-align:top}.side-to-side{clear:both}.prp-qualitytext{border-radius:3px;padding:0 2px}.prp-quality0{background-color:#ddd}.prp-quality1{background-color:#ffabab}.prp-quality2{background-color:#bbf}.prp-quality3{background-color:#ffe867}.prp-quality4{background-color:#90ff90}.link-loading{font-style:italic;pointer-events:none}.pageinfo .top-editors-charts{display:block;margin-bottom:25px;position:relative}.pageinfo .top-editors-charts canvas{max-height:250px;max-width:250px}.pageinfo .year-count-charts{clear:both;display:block;margin-bottom:25px;position:relative}.pageinfo .year-count-charts canvas{max-width:800px}.pageinfo .chart-wrapper{display:flex;flex-wrap:wrap;float:left;margin-right:50px}.pageinfo #textshares_chart{display:none;height:auto;width:auto}.pageinfo .chart-title{font-weight:700;text-align:center}.pageinfo .chart-legend{align-self:center;margin-left:15px}.pageinfo .chart-legend .color-icon{vertical-align:-4px}.pageinfo .legend-label{display:inline-block;max-width:200px;overflow-x:hidden;text-overflow:ellipsis;vertical-align:middle}.pageinfo .legend-value{vertical-align:bottom}.pageinfo .top-editors-table{margin-bottom:0}.pageinfo .footnotes{margin-bottom:20px;margin-top:5px}.pageinfo .month-counts-table .stripes-column{height:30px;padding:0}.pageinfo .month-counts-table .stripes{height:33.3333333333%}.pageinfo .sort-entry--assessment,.pageinfo .sort-entry--importance{font-weight:700;text-align:center}.pageinfo .sort-entry--assessment a,.pageinfo .sort-entry--importance a{color:#4365cc}.pageinfo .bugs-table .sort-entry--explanation{min-width:400px;white-space:pre-wrap}@media (max-width:767px){.pageinfo #year_count_legend{margin:0}.pageinfo #year_count_legend .legend-body>div{display:inline;margin-right:10px}}.autoedits #summary .chart-wrapper,.autoedits #summary .chart-wrapper canvas{max-width:300px}.autoedits #summary .chart-wrapper .chart-legend{display:none}.autoedits #tool_chart{display:none;height:auto;width:auto}.autoedits .autoedits-stat-list{margin-bottom:0;padding-bottom:0}.autoedits .tool-selector-form{margin-bottom:20px}.autoedits .tool-selector-form .btn-primary{vertical-align:bottom}.autoedits .tool-selector-form .form-submit{margin-bottom:0}.autoedits .contributions-container--loading .tool-selector-form{visibility:hidden}.autoedits #toolSelector{display:inline-block;width:auto}.autoedits .tooltipcss--tool-counts{cursor:help;position:relative;vertical-align:-1px}.autoedits .tooltipcss--tool-counts .tooltip-body{min-width:300px}.autoedits .footnotes{margin:20px 0 0}.blame .diff{border:0;border-collapse:initial;border-spacing:4px;margin:0;width:100%}.blame .diff td{padding:.33em .5em}.blame .diff td div{word-wrap:break-word}.blame .diff-lineno{font-weight:700}.blame .diff-marker{font-size:1.25em;padding:.25em;text-align:right}.blame .diff-context{background:#f8f9fa;border-color:#eaecf0;color:#222}.blame .diff-deletedline{border-color:#ffe49c}.blame .diff-deletedline .diffchange{background:#feeec8}.blame .diff-addedline{border-color:#a3d3ff}.blame .diff-addedline .diffchange{background:#d8ecff}.blame .diff-addedline,.blame .diff-context,.blame .diff-deletedline{border-radius:.33em;border-style:solid;border-width:1px 1px 1px 4px;line-height:1.6;vertical-align:top;white-space:pre-wrap}.blame .diff-addedline .diffchange,.blame .diff-deletedline .diffchange{border-radius:.33em;padding:.25em 0}.blame .diffchange{text-decoration:none}.categoryedits .toggle-table--chart{margin-left:50px}.categoryedits #category_chart{display:none;height:auto;width:auto}.categoryedits .select2-selection--multiple{border-color:#ccc!important;border-radius:0}.categoryedits .select2-results__option,.categoryedits .select2-selection__rendered{padding-left:12px!important}.categoryedits .select2-results__message{display:none}.categoryedits .select2-results__option--highlighted{background:#3379b7!important}.categoryedits .loading-results{display:none}.editcounter .stat-list .table{margin:0 auto}.editcounter .stat-list .table.rights-changes-summary{margin:0}.editcounter .stat-list--empty-group{font-weight:400!important}.editcounter .stat-list--empty-group:after{content:""!important}.editcounter #timecard-bubble-chart{max-width:1100px}.editcounter #monthcounts-canvas,.editcounter #yearcounts-canvas{max-width:1000px}.editcounter .yearmonth-container{display:flex;flex-direction:row;flex-wrap:wrap;width:100%}.editcounter .yearmonth-chart-container{flex-grow:1}.editcounter #namespace-canvas{height:auto;width:auto}.editcounter div.chart-wrapper.qualitychangechart{float:none}.editcounter .top-project-edit-counts td{white-space:nowrap}.editcounter .footnotes{clear:both;padding-top:20px}.editcounter .tooltipcss--pages-created{position:relative;vertical-align:-1px}.editcounter .tooltipcss--pages-created:hover{text-decoration:none}.editcounter .tooltipcss--pages-created .tooltip-body{min-width:300px}.editcounter .pending{opacity:.5}.home #wrapper{background:transparent}.home .splash-logo{margin-top:30px;max-width:320px;width:100%}.home .tool-list{padding-top:10px;text-align:left}.home .tool-list .btn{display:block;margin-top:15px;position:relative;text-align:left;white-space:normal;width:100%}.home .tool-name{font-weight:700}@media (max-width:767px){.home .splash-logo{max-width:450px;width:90%}}.meta #api_usage_chart,.meta #usage_chart{max-width:1000px}.pages .panel-body h3{clear:both}.pages .panel-body h4:not(:first-child){padding-top:10px}.pages .deleted-page{cursor:help;position:relative}.pages .deleted-page .tooltip-body{left:67%;width:300px}.pages .sort-entry--page-title{white-space:normal}.topedits .color-icon{margin-right:3px}.topedits .footnotes{margin:20px 0}@media (max-width:767px){.basic-info-charts{display:none;margin-bottom:0}}@media (prefers-color-scheme:dark){:root{scrollbar-color:#aaa transparent}#wrapper,.panel-body,.xt-toc,body{background:#181818;color:#aaa}.xt-toc.fixed{box-shadow:5px 5px 8px #333}.home-link:after,[role=separator]{display:none}.dropdown-menu,.dropdown.open,.form-label{background:#222}.dropdown-menu{box-shadow:0 6px 12px #333}.dropdown-menu>.active>a,.dropdown-menu>li>a{background:#222;color:#999}.dropdown-menu>.active>a:hover,.dropdown-menu>li>a:hover{background:#333;color:#999}.navbar-default{border:0}.navbar-default .navbar-nav>li>a{color:#999}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#aaa}header{background:#212121!important}.stat-list--group>tr:first-child>td{border-color:#606060!important}.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-color:#606060}.table-striped>tbody>tr:hover,.table-striped>tbody>tr:nth-of-type(odd){background-color:transparent}.table-sticky-header .sticky-heading{background:#111}a,a:hover{color:#09f}.panel-primary,.panel-primary .panel-heading{border-color:#606060}.help-icon,.help-icon:hover,.panel-primary>.panel-heading,.panel-primary>.panel-heading a,.panel-primary>.panel-heading a:hover,h4{color:#aaa}.panel{background-color:transparent}.form-control,.form-label,.panel-default,.panel-default>.panel-heading,hr{border-color:#333}.form-control:focus{border-color:#606060;box-shadow:inset 0 1px 1px #333,0 0 8px #333}.form-control,.form-label,label{color:#999}.form-control{background:#111;color:#999}.blame .diff-context{background:transparent;color:#aaa}.diffchange-inline{color:#333}.btn-default{background:#222;border-color:#222;color:#999}.btn-default svg{fill:#999}.btn-default:hover svg,.open .btn-default svg{fill:#333}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--multiple .select2-selection__choice{background:transparent}.select2-dropdown{background-color:#222}.categoryedits .select2-selection--multiple{border-color:#333!important}.categoryedits .select2-results__option--highlighted,.select2-container--default .select2-results__option[aria-selected=true]{background-color:#333!important}.alert .close{opacity:.8;text-shadow:none}.alert-info{background:#012;border:0;color:#999}.alert-warning{background-color:#8a6d3b;color:#fcf8e3}.alert-danger{background-color:#a94442;border-color:#ff6b6b;color:#1b1818}.color-icon,canvas{background:transparent}.tooltipcss .tooltip-body{background:#333;border-color:#606060;box-shadow:5px 5px 8px #333;color:#aaa}.tooltipcss .tooltip-body:before{border-color:transparent #606060 transparent transparent}.tooltipcss .tooltip-body:after{border-color:transparent #333 transparent transparent}body.rtl .tooltipcss .tooltip-body:before{border-color:transparent transparent transparent #606060}body.rtl .tooltipcss .tooltip-body:after{border-color:transparent transparent transparent #333}.diff-pos,.diff-pos:before{color:#00b400}.diff-neg{color:#f73d3d}.pagination>li>a{background-color:transparent;border-color:#606060;color:#aaa}.pagination>li>a:focus,.pagination>li>a:hover{background-color:#333;border-color:#606060;color:#aaa}.pagination>li.disabled>span{background-color:inherit;border-color:inherit;opacity:.5}.pagination>li.active>a,.pagination>li.active>a:focus,.pagination>li.active>a:hover{background-color:#333;border-color:#606060;color:#aaa}.reverted-edit{background:#4e4c40!important}.reverted-edit .text-info{color:#74cefa}.reverted-edit .text-muted{color:#909090}.footer-branding,.home-link,.splash-logo{filter:invert(1) hue-rotate(180deg)}.prp-qualitytext{color:#fff}.prp-quality0{background-color:#55585e}.prp-quality1{background-color:#971602}.prp-quality2{background-color:#40408c}.prp-quality3{background-color:#5c5c00}.prp-quality4{background-color:#00662e}}@media (min-width:1200px){.xt-panel-body table{width:auto}}@media (max-width:1200px){.stat-list>.table td:first-child,.stat-list>.table th:first-child{width:300px}}@media (max-width:992px){.error-mascot{width:200px}}@media (max-width:767px){.basic-info-charts .chart-wrapper{display:block!important}.basic-info-charts .chart-wrapper .chart-legend{margin-bottom:15px}.app-footer{position:relative}.footer-branding{float:left!important;margin-bottom:10px}.footer-about{clear:both;display:block;max-width:100%}.footer-quote{overflow-x:initial;text-overflow:clip;white-space:normal}.xt-panel-description{display:block}.stat-list{padding:0}.stat-list>.table td:first-child,.stat-list>.table th:first-child{display:block;padding-top:5px;text-align:left!important}.stat-list>.table td:first-child.stat-list--new-group,.stat-list>.table th:first-child.stat-list--new-group{padding-top:15px!important}.stat-list>.table td .stat-list--new-group,.stat-list>.table td:last-child,.stat-list>.table th .stat-list--new-group,.stat-list>.table th:last-child{display:block;padding-top:0!important}.stat-list--group{padding-top:15px!important}.stat-list--group tr>td{padding-top:5px!important}.stat-list--group>tr:first-child>td{text-align:left!important}.back-to-search{font-size:0}.back-to-search .glyphicon{font-size:medium}.tool-links{clear:both;width:100%}.xt-panel-body{padding:15px 0}.panel-default{border-radius:0}#wrapper{margin:15px 5px}.xt-toc{display:none}.toggle-table--chart{margin:0;max-width:100%}.xt-page-title>small{display:block;padding:3px 0 10px}.xt-page-title>small:before{display:none}.input-group{margin-bottom:15px;width:100%}.input-group .tooltipcss{display:none}.input-group input[type=text]{border-radius:0!important}.input-group-addon{background:transparent;border:0;display:block;padding:0}.input-group-addon:first-child{font-weight:700}.input-group-addon:last-child{clear:both;padding-top:5px}.error-wrapper{padding:10px}.error-mascot--wrapper{margin-bottom:20px;text-align:center;width:100%}.times-in-utc{padding:0 15px}} \ No newline at end of file diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json index 615e9bc44..e22c15d38 100644 --- a/public/build/entrypoints.json +++ b/public/build/entrypoints.json @@ -7,7 +7,7 @@ "/build/app.1d427a43.js" ], "css": [ - "/build/app.7692d209.css" + "/build/app.ed7ba741.css" ] } } diff --git a/public/build/manifest.json b/public/build/manifest.json index aa0bf6aea..a52e201d7 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -1,5 +1,5 @@ { - "build/app.css": "/build/app.7692d209.css", + "build/app.css": "/build/app.ed7ba741.css", "build/app.js": "/build/app.1d427a43.js", "build/runtime.js": "/build/runtime.c217f8c4.js", "build/852.96913092.js": "/build/852.96913092.js", diff --git a/src/Model/AdminScore.php b/src/Model/AdminScore.php index f6dcc6c9a..f7e7ad2ea 100644 --- a/src/Model/AdminScore.php +++ b/src/Model/AdminScore.php @@ -10,6 +10,7 @@ /** * An AdminScore provides scores of logged actions and on-wiki activity made by a user, * to measure if they would be suitable as an administrator. + * @codeCoverageIgnore */ class AdminScore extends Model { /** diff --git a/src/Model/Authorship.php b/src/Model/Authorship.php index 155b40de4..efd11e79a 100644 --- a/src/Model/Authorship.php +++ b/src/Model/Authorship.php @@ -69,6 +69,8 @@ private function getTargetRevId( ?string $target ): ?int { /** * Domains of supported wikis. * @return string[] + * Is a constant. + * @codeCoverageIgnore */ public function getSupportedWikis(): array { return self::SUPPORTED_PROJECTS; @@ -100,18 +102,18 @@ public function getError(): ?string { /** * Get the total number of authors. - * @return int + * @return int|null */ - public function getTotalAuthors(): int { - return $this->data['totalAuthors']; + public function getTotalAuthors(): int|null { + return $this->data['totalAuthors'] ?? null; } /** * Get the total number of characters added. - * @return int + * @return int|null */ - public function getTotalCount(): int { - return $this->data['totalCount']; + public function getTotalCount(): int|null { + return $this->data['totalCount'] ?? null; } /** diff --git a/src/Model/AutoEdits.php b/src/Model/AutoEdits.php index ee8b9b7ba..e279de60b 100644 --- a/src/Model/AutoEdits.php +++ b/src/Model/AutoEdits.php @@ -231,6 +231,8 @@ public function getToolCounts(): array { /** * Get a list of all available tools for the Project. * @return array + * Just passes along a repository result. + * @codeCoverageIgnore */ public function getAllTools(): array { return $this->repository->getTools( $this->project ); diff --git a/src/Model/EditCounter.php b/src/Model/EditCounter.php index 0b64dd0fb..cfd1574f4 100644 --- a/src/Model/EditCounter.php +++ b/src/Model/EditCounter.php @@ -44,10 +44,10 @@ class EditCounter extends Model { protected array $timeCardData; /** - * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'. + * Various data on the last 5000 edits. * @var string[] As returned by the DB, unconverted to int or float */ - protected array $editSizeData; + protected array $editData; /** * Duration of the longest block in seconds; -1 if indefinite, @@ -139,15 +139,17 @@ public function getThanksReceived(): int { * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks. * @return array */ - protected function getBlocks( string $type, bool $blocksOnly = true ): array { + public function getBlocks( string $type, bool $blocksOnly = true ): array { if ( isset( $this->blocks[$type] ) && is_array( $this->blocks[$type] ) ) { - return $this->blocks[$type]; + $blocks = $this->blocks[$type]; + } else { + $method = "getBlocks" . ucfirst( $type ); + $blocks = $this->repository->$method( $this->project, $this->user ); + $this->blocks[$type] = $blocks; } - $method = "getBlocks" . ucfirst( $type ); - $blocks = $this->repository->$method( $this->project, $this->user ); - $this->blocks[$type] = $blocks; // Filter out unblocks unless requested. + // Expressly don't store this. if ( $blocksOnly ) { $blocks = array_filter( $blocks, static function ( $block ) { return $block['log_action'] === 'block' || $block['log_action'] === 'reblock'; @@ -352,7 +354,7 @@ public function getLongestBlockSeconds() { // Reset the last block, as it has now been accounted for. $lastBlock = [ null, null ]; } - } elseif ( $block['log_action'] === 'reblock' && -1 !== $lastBlock[1] ) { + } elseif ( $block['log_action'] === 'reblock' && $lastBlock[1] !== -1 ) { // The last block was modified. // $lastBlock is left unchanged if its duration was indefinite. @@ -726,27 +728,28 @@ public function timeCard(): array { foreach ( $totals as $total ) { $max = max( $max, $total['value'] ); } - foreach ( $totals as &$total ) { - $total['scale'] = round( ( $total['value'] / $max ) * 20 ); + foreach ( $totals as $index => $total ) { + $totals[$index]['scale'] = round( ( $total['value'] / $max ) * 20 ); } // Fill in zeros for timeslots that have no values. $sortedTotals = []; $index = 0; - $sortedIndex = 0; foreach ( range( 1, 7 ) as $day ) { foreach ( range( 0, 23 ) as $hour ) { - if ( isset( $totals[$index] ) && (int)$totals[$index]['hour'] === $hour ) { - $sortedTotals[$sortedIndex] = $totals[$index]; + if ( isset( $totals[$index] ) + && (int)$totals[$index]['day_of_week'] === $day + && (int)$totals[$index]['hour'] === $hour + ) { + $sortedTotals[] = $totals[$index]; $index++; } else { - $sortedTotals[$sortedIndex] = [ + $sortedTotals[] = [ 'day_of_week' => $day, 'hour' => $hour, 'value' => 0, ]; } - $sortedIndex++; } } @@ -765,10 +768,12 @@ public function monthCounts( ?DateTime $currentTime = null ): array { return $this->monthCounts; } + // @codeCoverageIgnoreStart // Set to current month if we're not unit-testing if ( !( $currentTime instanceof DateTime ) ) { $currentTime = new DateTime( 'last day of this month' ); } + // @codeCoverageIgnoreEnd $totals = $this->repository->getMonthCounts( $this->project, $this->user ); $out = [ @@ -837,8 +842,6 @@ public function monthCountsWithNamespaces( ?DateTime $currentTime = null ): arra * string[] - Modified $out filled with month stats, * DateTime - timestamp of first edit * ] - * Tests covered in self::monthCounts(). - * @codeCoverageIgnore */ private function fillInMonthCounts( array $out, array $totals, DateTime $firstEdit ): array { foreach ( $totals as $total ) { @@ -861,8 +864,6 @@ private function fillInMonthCounts( array $out, array $totals, DateTime $firstEd * @param array $out * @param DatePeriod $dateRange From first edit to present. * @return array Modified $out filled with month stats. - * Tests covered in self::monthCounts(). - * @codeCoverageIgnore */ private function fillInMonthTotalsAndLabels( array $out, DatePeriod $dateRange ): array { foreach ( $dateRange as $monthObj ) { @@ -958,15 +959,15 @@ public function yearTotals( ?DateTime $currentTime = null ): array { } /** - * Get average edit size, and number of large and small edits. - * @return array + * Get average edit size, number of large and small edits, and change tags. + * @return array With keys "sizes", "average_size", "small_edits", "large_edits", "tag_lists". */ - public function getEditSizeData(): array { - if ( !isset( $this->editSizeData ) ) { - $this->editSizeData = $this->repository - ->getEditSizeData( $this->project, $this->user ); + public function getEditData(): array { + if ( !isset( $this->editData ) ) { + $this->editData = $this->repository + ->getEditData( $this->project, $this->user ); } - return $this->editSizeData; + return $this->editData; } /** @@ -979,21 +980,39 @@ public function countLast5000(): int { } /** - * Get the number of edits under 20 bytes of the user's past 5000 edits. - * @return int - */ - public function countSmallEdits(): int { - $editSizeData = $this->getEditSizeData(); - return isset( $editSizeData['small_edits'] ) ? (int)$editSizeData['small_edits'] : 0; - } - - /** - * Get the total number of edits over 1000 bytes of the user's past 5000 edits. - * @return int + * Get the ProofreadPage tagged quality changes in the last 5000 edits. + * @return int[] With keys 0, 1, 2, 3, 4, and 'total'. */ - public function countLargeEdits(): int { - $editSizeData = $this->getEditSizeData(); - return isset( $editSizeData['large_edits'] ) ? (int)$editSizeData['large_edits'] : 0; + public function countQualityChanges(): array { + $editData = $this->getEditData(); + $res = [ + 0 => 0, + 1 => 0, + 2 => 0, + 3 => 0, + 4 => 0, + 'total' => 0, + ]; + if ( !isset( $editData['tag_lists'] ) ) { + return $res; + } + $tagLists = $editData['tag_lists']; + foreach ( $tagLists as $list ) { + if ( $list !== null ) { + $found = false; + foreach ( $list as $tag ) { + if ( preg_match( '/^proofreadpage\-quality[0-4]$/', $tag ) ) { + $res[intval( substr( $tag, -1 ) )] += 1; + $found = true; + break; + } + } + if ( $found ) { + $res['total'] += 1; + } + } + } + return $res; } /** @@ -1001,11 +1020,11 @@ public function countLargeEdits(): int { * @return int */ public function countAutoEdits(): int { - $editSizeData = $this->getEditSizeData(); - if ( !isset( $editSizeData['tag_lists'] ) ) { + $editData = $this->getEditData(); + if ( !isset( $editData['tag_lists'] ) ) { return 0; } - $tags = json_decode( $editSizeData['tag_lists'] ); + $tags = $editData['tag_lists']; $autoTags = $this->autoEditsHelper->getTags( $this->project ); return count( // Number @@ -1032,9 +1051,9 @@ public function countAutoEdits(): int { * @return float Size in bytes. */ public function averageEditSize(): float { - $editSizeData = $this->getEditSizeData(); - if ( isset( $editSizeData['average_size'] ) ) { - return round( (float)$editSizeData['average_size'], 3 ); + $editData = $this->getEditData(); + if ( isset( $editData['average_size'] ) ) { + return round( (float)$editData['average_size'], 3 ); } else { return 0; } diff --git a/src/Model/LargestPages.php b/src/Model/LargestPages.php index 7bde25c1b..1282040e5 100644 --- a/src/Model/LargestPages.php +++ b/src/Model/LargestPages.php @@ -50,6 +50,8 @@ public function getExcludePattern(): string { /** * Get the largest pages on the project. * @return Page[] + * Just returns a repository result. + * @codeCoverageIgnore */ public function getResults(): array { return $this->repository->getData( diff --git a/src/Model/Model.php b/src/Model/Model.php index 1ad6a85e5..fe775e732 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -60,8 +60,11 @@ public function setRepository( Repository $repository ): Model { */ public function getRepository(): Repository { if ( !isset( $this->repository ) ) { + // Untestable, Model cannot be directly instantiated and all subclasses set it in __construct. + // @codeCoverageIgnoreStart $msg = sprintf( 'The $repository property for class %s must be set before using.', static::class ); throw new Exception( $msg ); + // @codeCoverageIgnoreEnd } return $this->repository; } diff --git a/src/Model/Page.php b/src/Model/Page.php index 6489ba1f6..c5c1e4b20 100644 --- a/src/Model/Page.php +++ b/src/Model/Page.php @@ -202,7 +202,7 @@ public function getWatchers(): ?int { * Get the HTML content of the body of the page. * @param DateTime|int|null $target If a DateTime object, the * revision at that time will be returned. If an integer, it is - * assumed to be the actual revision ID. + * assumed to be the actual revision ID. If null, use the last revision. * @return string */ // phpcs:ignore MediaWiki.Usage.NullableType.ExplicitNullableTypes @@ -287,21 +287,19 @@ public function getNumRevisions( ?User $user = null, false|int $start = false, f * @param false|int $start * @param false|int $end * @param int|null $limit - * @param int|null $numRevisions * @return array */ public function getRevisions( ?User $user = null, false|int $start = false, false|int $end = false, - ?int $limit = null, - ?int $numRevisions = null + ?int $limit = null ): array { if ( isset( $this->revisions ) ) { return $this->revisions; } - $this->revisions = $this->repository->getRevisions( $this, $user, $start, $end, $limit, $numRevisions ); + $this->revisions = $this->repository->getRevisions( $this, $user, $start, $end, $limit ); return $this->revisions; } @@ -324,32 +322,27 @@ public function getWikitext(): ?string { * @see PageRepository::getRevisionsStmt() * @param User|null $user Specify to get only revisions by the given user. * @param ?int $limit Max number of revisions to process. - * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the - * OFFSET if we are given a $limit. If $limit is set and $numRevisions is not set, a - * separate query is ran to get the nuber of revisions. * @param false|int $start * @param false|int $end * @return Result + * Just returns a Repo result. + * @codeCoverageIgnore */ public function getRevisionsStmt( ?User $user = null, ?int $limit = null, - ?int $numRevisions = null, false|int $start = false, false|int $end = false ): Result { - // If we have a limit, we need to know the total number of revisions so that PageRepo - // will properly set the OFFSET. See PageRepository::getRevisionsStmt() for more info. - if ( isset( $limit ) && $numRevisions === null ) { - $numRevisions = $this->getNumRevisions( $user, $start, $end ); - } - return $this->repository->getRevisionsStmt( $this, $user, $limit, $numRevisions, $start, $end ); + return $this->repository->getRevisionsStmt( $this, $user, $limit, $start, $end ); } /** * Get the revision ID that immediately precedes the given date. * @param DateTime $date * @return int|null Null if none found. + * Just returns a Repo result. + * @codeCoverageIgnore */ public function getRevisionIdAtDate( DateTime $date ): ?int { return $this->repository->getRevisionIdAtDate( $this, $date ); diff --git a/src/Model/PageAssessments.php b/src/Model/PageAssessments.php index dc59ddbaf..36e037eab 100644 --- a/src/Model/PageAssessments.php +++ b/src/Model/PageAssessments.php @@ -102,7 +102,7 @@ public function getBadgeURL( ?string $class, bool $filenameOnly = false ): strin } elseif ( isset( $config['class']['Unknown'] ) ) { $url = 'https://upload.wikimedia.org/wikipedia/commons/' . $config['class']['Unknown']['badge']; } else { - $url = ""; + $url = ''; } if ( $filenameOnly ) { @@ -256,7 +256,7 @@ private function getClassFromAssessment( array $assessment ): array { * @param array $assessment * @return array|null Decorated importance assessment. Null if importance could not be determined. */ - private function getImportanceFromAssessment( array $assessment ): ?array { + public function getImportanceFromAssessment( array $assessment ): ?array { $importanceValue = $assessment['importance']; if ( $importanceValue == '' && !isset( $this->getConfig()['importance'] ) ) { diff --git a/src/Model/PageInfo.php b/src/Model/PageInfo.php index bfcd56ec5..c20856ea0 100644 --- a/src/Model/PageInfo.php +++ b/src/Model/PageInfo.php @@ -160,7 +160,6 @@ public function getNumRevisionsProcessed(): int { /** * Fetch and store all the data we need to show the PageInfo view. - * @codeCoverageIgnore */ public function prepareData(): void { $this->parseHistory(); @@ -390,6 +389,8 @@ public function getMaxDeletion(): ?Edit { /** * Get the subpage count. * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function getSubpageCount(): int { return $this->repository->getSubpageCount( $this->page ); @@ -436,7 +437,7 @@ public function topTenEditorsByAdded(): array { * @return array */ public function getYearMonthCounts(): array { - return $this->yearMonthCounts; + return $this->yearMonthCounts ?? []; } /** @@ -475,10 +476,6 @@ public function getTools(): array { /** * Parse the revision history, collecting our core statistics. - * - * Untestable because it relies on getting a PDO statement. All the important - * logic lives in other methods which are tested. - * @codeCoverageIgnore */ private function parseHistory(): void { $limit = $this->tooManyRevisions() ? $this->repository->getMaxPageRevisions() : null; @@ -519,18 +516,6 @@ private function parseHistory(): void { /** @var Edit $edit */ $edit = $this->repository->getEdit( $this->page, $rev ); - if ( $edit->getDeleted() !== 0 ) { - $this->numDeletedRevisions++; - } - - if ( in_array( 'mobile edit', $edit->getTags() ) ) { - $this->mobileCount++; - } - - if ( in_array( 'visualeditor', $edit->getTags() ) ) { - $this->visualCount++; - } - if ( $revCount === 0 ) { $this->firstEdit = $edit; } @@ -549,7 +534,10 @@ private function parseHistory(): void { // Various sorts arsort( $this->editors ); - ksort( $this->yearMonthCounts ); + if ( isset( $this->yearMonthCounts ) ) { + // Might not be if there are no edits + ksort( $this->yearMonthCounts ); + } if ( $this->tools ) { arsort( $this->tools ); } @@ -580,6 +568,18 @@ private function updateCounts( Edit $edit, array $prevEdits ): array { // Update figures regarding content addition/removal, and the revert count. $prevEdits = $this->updateContentSizes( $edit, $prevEdits ); + if ( $edit->getDeleted() !== 0 ) { + $this->numDeletedRevisions++; + } + + if ( in_array( 'mobile edit', $edit->getTags() ) ) { + $this->mobileCount++; + } + + if ( in_array( 'visualeditor', $edit->getTags() ) ) { + $this->visualCount++; + } + // Now that we've updated all the counts, we can reset // the prev and last edits, which are used for tracking. // But first, let's copy over the SHA of the actual previous edit @@ -606,6 +606,7 @@ private function updateContentSizes( Edit $edit, array $prevEdits ): array { $edit->setReverted( true ); return $this->updateContentSizesRevert( $prevEdits ); } else { + $edit->setReverted( false ); return $this->updateContentSizesNonRevert( $edit, $prevEdits ); } } @@ -663,7 +664,7 @@ private function updateContentSizesRevert( array $prevEdits ): array { * @return Edit[] Updated version of $prevEdits, for tracking. */ private function updateContentSizesNonRevert( Edit $edit, array $prevEdits ): array { - $editSize = $this->getEditSize( $edit, $prevEdits ); + $editSize = $edit->getSize(); // Edit was not a revert, so treat size > 0 as content added. if ( $editSize > 0 ) { @@ -692,24 +693,6 @@ private function updateContentSizesNonRevert( Edit $edit, array $prevEdits ): ar return $prevEdits; } - /** - * Get the size of the given edit, based on the previous edit (if present). - * We also don't return the actual edit size if last revision had a length of null. - * This happens when the edit follows other edits that were revision-deleted. - * @see T148857 for more information. - * @todo Remove once T101631 is resolved. - * @param Edit $edit - * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'. - * @return int|null - */ - private function getEditSize( Edit $edit, array $prevEdits ): ?int { - if ( $prevEdits['prev'] && $prevEdits['prev']->getLength() === null ) { - return 0; - } else { - return $edit->getSize(); - } - } - /** * Update counts of automated tool usage for the given edit. * @param Edit $edit @@ -954,9 +937,8 @@ private function doPostPrecessing(): void { } // Compute the percentage of minor edits the user made. - $this->editors[$editor]['minorPercentage'] = $info['all'] - ? ( $info['minor'] / $info['all'] ) * 100 - : 0; + // Note: $info['all'] is ensured to be non-null because we ++ it as soon as we add the editor + $this->editors[$editor]['minorPercentage'] = ( $info['minor'] / $info['all'] ) * 100; if ( $info['all'] > 1 ) { // Number of seconds/days between first and last edit. diff --git a/src/Model/PageInfoApi.php b/src/Model/PageInfoApi.php index cf3641a3a..67421a55d 100644 --- a/src/Model/PageInfoApi.php +++ b/src/Model/PageInfoApi.php @@ -92,6 +92,8 @@ public function tooManyRevisions(): bool { * intentionally disabled, because using the gadget, this will get hit for a different page constantly, where * the likelihood of cache benefiting us is slim. * @return string[]|false false if the page was not found. + * Just returns a repository result. + * @codeCoverageIgnore */ public function getBasicEditingInfo() { return $this->repository->getBasicEditingInfo( $this->page ); @@ -104,10 +106,8 @@ public function getBasicEditingInfo() { * @return array */ public function getTopEditorsByEditCount( int $limit = 20, bool $noBots = false ): array { - // Quick cache, valid only for the same request. - static $topEditors = null; - if ( $topEditors !== null ) { - return $topEditors; + if ( isset( $this->topEditors ) ) { + return $this->topEditors; } $rows = $this->repository->getTopEditorsByEditCount( @@ -118,10 +118,10 @@ public function getTopEditorsByEditCount( int $limit = 20, bool $noBots = false $noBots ); - $topEditors = []; + $this->topEditors = []; $rank = 0; foreach ( $rows as $row ) { - $topEditors[] = [ + $this->topEditors[] = [ 'rank' => ++$rank, 'username' => $row['username'], 'count' => $row['count'], @@ -139,7 +139,7 @@ public function getTopEditorsByEditCount( int $limit = 20, bool $noBots = false ]; } - return $topEditors; + return $this->topEditors; } /** @@ -462,6 +462,8 @@ public function getNumBots(): int { /** * Get counts of (semi-)automated tools used to edit the page. * @return array + * Just returns a repository result. + * @codeCoverageIgnore */ public function getAutoEditsCounts(): array { return $this->repository->getAutoEditsCounts( $this->page, $this->start, $this->end ); diff --git a/src/Model/Pages.php b/src/Model/Pages.php index e70452b2d..0a8a1033d 100644 --- a/src/Model/Pages.php +++ b/src/Model/Pages.php @@ -34,9 +34,6 @@ class Pages extends Model { /** @var array Number of redirects/pages that were created/deleted, broken down by namespace. */ protected array $countsByNamespace; - /** @var bool Whether to only get the counts */ - protected bool $countsOnly; - /** * Pages constructor. * @param Repository|PagesRepository $repository @@ -60,12 +57,11 @@ public function __construct( protected int|false $start = false, protected int|false $end = false, protected int|false $offset = false, - bool $countsOnly = false, + protected bool $countsOnly = false, ) { $this->namespace = $namespace === 'all' ? 'all' : (int)$namespace; $this->redirects = $redirects ?: self::REDIR_NONE; $this->deleted = $deleted ?: self::DEL_ALL; - $this->countsOnly = $countsOnly; } /** @@ -259,6 +255,11 @@ public function getCounts(): array { if ( self::REDIR_NONE !== $this->redirects ) { $counts[$ns]['redirects'] = (int)$row['redirects']; } + if ( $this->project->isPrpPage( $ns ) ) { + foreach ( [ 0, 1, 2, 3, 4 ] as $level ) { + $counts[$ns]["prp_quality$level"] = (int)$row["prp_quality$level"]; + } + } } $this->countsByNamespace = $counts; @@ -282,11 +283,9 @@ public function getAssessmentCounts(): array { foreach ( $this->pages as $ns => $nsPages ) { if ( $this->project->hasPageAssessments( $ns ) ) { foreach ( $nsPages as $page ) { - if ( !isset( $counts[$page['assessment']['class'] ?: 'Unknown'] ) ) { - $counts[$page['assessment']['class'] ?: 'Unknown'] = 1; - } else { - $counts[$page['assessment']['class'] ?: 'Unknown']++; - } + $class = $page['assessment']['class'] ?: 'Unknown'; + $counts[$class] ??= 0; + $counts[$class]++; } } } @@ -481,6 +480,10 @@ private function formatPages( array $pages ): array { ]; } + if ( array_key_exists( 'prp_quality', $row ) ) { + $pageData['prp_quality'] = (int)$row['prp_quality']; + } + $results[$row['namespace']][] = $pageData; } diff --git a/src/Model/Project.php b/src/Model/Project.php index 6647fcb5b..4e80cee72 100644 --- a/src/Model/Project.php +++ b/src/Model/Project.php @@ -63,6 +63,31 @@ public function hasPageAssessments( int|string|null $nsId = null ): bool { } } + /** + * Whether or not this namespace is the Page namespace (of ProofreadPage). + * Or true if it is 'all'. + * @param int|string $namespace Namespace ID, or 'all'. + * @return bool + */ + public function isPrpPage( $namespace ): bool { + return $this->hasProofreadPage() && + ( + !is_numeric( $namespace ) || + $this->getCanonicalNamespace( $namespace ) === 'Page' + ); + } + + /** + * Get the list of the names of each ProofreadPage + * quality level. Keys are 0, 1, 2, 3, and 4. + * @return string[] + * Just returns a Repository result. + * @codeCoverageIgnore + */ + public function getPrpQualityNames(): array { + return $this->repository->getPrpQualityNames( $this ); + } + /** * Unique identifier this Project, to be used in cache keys. * @see Repository::getCacheKey() @@ -215,7 +240,22 @@ public function getTitle(): string { */ public function getNamespaces(): array { $metadata = $this->getMetadata(); - return $metadata['namespaces']; + return ( $metadata === null ? [] : $metadata['namespaces'] ); + } + + /** + * Get the canonical namespace name for a namespace ID. + * Or '' if the namespace does not exist. + * @param int $namespace + * @return string + */ + public function getCanonicalNamespace( int $namespace ): string { + $canonicalNamespaces = $this->getMetadata()['canonical_namespaces']; + if ( array_key_exists( $namespace, $canonicalNamespaces ) ) { + return $canonicalNamespaces[$namespace]; + } else { + return ''; + } } /** @@ -232,14 +272,12 @@ public function getMainPage(): string { * @return string[] */ public function getInstalledExtensions(): array { - // Quick cache, valid only for the same request. - static $installedExtensions = null; - if ( is_array( $installedExtensions ) ) { - return $installedExtensions; + if ( !isset( $this->installedExtensions ) || + !is_array( $this->installedExtensions ) + ) { + $this->installedExtensions = $this->getRepository()->getInstalledExtensions( $this ); } - - $installedExtensions = $this->getRepository()->getInstalledExtensions( $this ); - return $installedExtensions; + return $this->installedExtensions; } /** @@ -251,6 +289,15 @@ public function hasPageTriage(): bool { return in_array( 'PageTriage', $extensions ); } + /** + * Get if this Wiki has the ProofreadPage extension. + * @return bool + */ + public function hasProofreadPage(): bool { + $extensions = $this->getInstalledExtensions(); + return in_array( 'ProofreadPage', $extensions ); + } + /** * Whether this wiki has the VisualEditor extension enabled. * @return bool diff --git a/src/Model/TopEdits.php b/src/Model/TopEdits.php index 09d0aa168..0ef70e1a1 100644 --- a/src/Model/TopEdits.php +++ b/src/Model/TopEdits.php @@ -148,6 +148,7 @@ public function getNumTopEdits(): int { * Get the WikiProject totals. * @param int $ns Namespace ID. * @return array + * @codeCoverageIgnore Just returns a repository result. */ public function getProjectTotals( int $ns ): array { return $this->repository->getProjectTotals( diff --git a/src/Model/User.php b/src/Model/User.php index acfe5449d..1de2b63cc 100644 --- a/src/Model/User.php +++ b/src/Model/User.php @@ -191,6 +191,8 @@ public function getId( Project $project ): ?int { * Get the user's actor ID on the given project. * @param Project $project * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function getActorId( Project $project ): int { return (int)$this->repository->getActorId( @@ -228,6 +230,8 @@ public function getUserRights( Project $project ): array { * Get a list of this user's global rights. * @param Project|null $project A project to query; if not provided, the default will be used. * @return string[] + * Just returns a repository result. + * @codeCoverageIgnore */ public function getGlobalUserRights( ?Project $project = null ): array { return $this->repository->getGlobalUserRights( $this->getUsername(), $project ); @@ -255,6 +259,8 @@ public function getEditCount( Project $project ): int { /** * Number of edits which if exceeded, will require the user to log in. * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function numEditsRequiringLogin(): int { return $this->repository->numEditsRequiringLogin(); @@ -280,6 +286,8 @@ public function existsOnProject( Project $project ): bool { /** * Does this user exist globally? * @return bool + * Just returns a repository result. + * @codeCoverageIgnore */ public function existsGlobally(): bool { return $this->repository->existsGlobally( $this ); @@ -401,6 +409,8 @@ public function hasTooManyEdits( Project $project ): bool { * @param false|int $start Start date as Unix timestamp. * @param int|false $end End date as Unix timestamp. * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function countEdits( Project $project, diff --git a/src/Model/UserRights.php b/src/Model/UserRights.php index 2dbcf5686..0d0fcd490 100644 --- a/src/Model/UserRights.php +++ b/src/Model/UserRights.php @@ -9,7 +9,6 @@ use App\Repository\UserRightsRepository; use DateInterval; use DateTimeImmutable; -use Exception; /** * An UserRights provides methods around parsing changes to a user's rights. @@ -282,18 +281,13 @@ private function processRightsChanges( array $logData ): array { } else { // This is the old school format that most likely contains // the list of rights additions as a comma-separated list. - try { - [ $old, $new ] = explode( "\n", $row['log_params'] ); - $old = array_filter( array_map( 'trim', explode( ',', $old ) ) ); - $new = array_filter( array_map( 'trim', explode( ',', (string)$new ) ) ); - $added = array_diff( $new, $old ); - $removed = array_diff( $old, $new ); - } catch ( Exception ) { - // Really, really old school format that may be missing metadata - // altogether. Here we'll just leave $added and $removed empty. - $added = []; - $removed = []; - } + // NB: none of these functions throw as long as log_params is a string or int, + // and we know it is. + [ $old, $new ] = explode( "\n", $row['log_params'] ); + $old = array_filter( array_map( 'trim', explode( ',', $old ) ) ); + $new = array_filter( array_map( 'trim', explode( ',', (string)$new ) ) ); + $added = array_diff( $new, $old ); + $removed = array_diff( $old, $new ); } // Remove '(none)'. @@ -405,10 +399,9 @@ public function hasImpossibleLogs(): bool { * Get the timestamp of when the user became autoconfirmed. * @return string|false YmdHis format, or false if date is in the future or if AC status could not be determined. */ - private function getAutoconfirmedTimestamp(): false|string { - static $acTimestamp = null; - if ( $acTimestamp !== null ) { - return $acTimestamp; + public function getAutoconfirmedTimestamp(): false|string { + if ( isset( $this->acTimestamp ) && $this->acTimestamp !== null ) { + return $this->acTimestamp; } if ( $this->user->isTemp( $this->project ) ) { @@ -447,18 +440,25 @@ private function getAutoconfirmedTimestamp(): false|string { // If more than wgAutoConfirmCount, then $acDate is when they became autoconfirmed. if ( $editsByAcDate >= $thresholds['wgAutoConfirmCount'] ) { - return $acDate; + $this->acTimestamp = $acDate; + } else { + // Now check when the nth edit was made, where n is wgAutoConfirmCount. + // This will be false if they still haven't made 10 edits. + $this->acTimestamp = $this->repository->getNthEditTimestamp( + $this->project, + $this->user, + $registrationDate->format( 'YmdHis' ), + $thresholds['wgAutoConfirmCount'] + ); + if ( $this->acTimestamp ) { + if ( is_string( $this->acTimestamp ) ) { + $this->acTimestamp = ( new \DateTime( $this->acTimestamp ) )->format( 'YmdHis' ); + } else { + $this->acTimestamp = $this->acTimestamp->format( 'YmdHis' ); + } + } } - // Now check when the nth edit was made, where n is wgAutoConfirmCount. - // This will be false if they still haven't made 10 edits. - $acTimestamp = $this->repository->getNthEditTimestamp( - $this->project, - $this->user, - $registrationDate->format( 'YmdHis' ), - $thresholds['wgAutoConfirmCount'] - ); - - return $acTimestamp; + return $this->acTimestamp; } } diff --git a/src/Repository/AdminStatsRepository.php b/src/Repository/AdminStatsRepository.php index d969e87c5..ae0a92db7 100644 --- a/src/Repository/AdminStatsRepository.php +++ b/src/Repository/AdminStatsRepository.php @@ -188,7 +188,6 @@ private function getUserGroupByLocality( array $res, array $permissions, bool $g // If they are able to add and remove user groups, we'll treat them as having the 'userrights' permission. if ( isset( $userGroup['add'] ) || isset( $userGroup['remove'] ) ) { $userGroup['rights'][] = 'userrights'; - $userGroup['rights'][] = 'userrights'; } if ( count( array_intersect( $userGroup['rights'], $permissions ) ) > 0 ) { $userGroups[] = $userGroup['name']; diff --git a/src/Repository/EditCounterRepository.php b/src/Repository/EditCounterRepository.php index 3bdbf3e95..61dc4c393 100644 --- a/src/Repository/EditCounterRepository.php +++ b/src/Repository/EditCounterRepository.php @@ -526,9 +526,10 @@ public function getTimeCard( Project $project, User $user ): array { * Will cache the result for 10 minutes. * @param Project $project The project. * @param User $user The user. - * @return string[] With keys 'average_size', 'small_edits' and 'large_edits' + * @return string[] With keys 'sizes, 'average_size', + * 'small_edits', 'large_edits', and 'tag_lists' */ - public function getEditSizeData( Project $project, User $user ): array { + public function getEditData( Project $project, User $user ): array { // Set up cache. $cacheKey = $this->getCacheKey( func_get_args(), 'ec_editsizes' ); if ( $this->cache->hasItem( $cacheKey ) ) { @@ -552,26 +553,27 @@ public function getEditSizeData( Project $project, User $user ): array { } $sql = "SELECT JSON_ARRAYAGG(data.size) as sizes, - JSON_ARRAYAGG(data.tags) as tag_lists - FROM ( - SELECT CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0) AS size, - ( - SELECT JSON_ARRAYAGG(ctd_name) - FROM $ctTable - JOIN $ctdTable - ON ct_tag_id = ctd_id - WHERE ct_rev_id = revs.rev_id - ) as tags - FROM $revisionTable AS revs - JOIN $pageTable ON revs.rev_page = page_id - $ipcJoin - LEFT JOIN $revisionTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id) - WHERE $whereClause - ORDER BY revs.rev_timestamp DESC - LIMIT 5000 - ) data"; + JSON_ARRAYAGG(data.tags) as tag_lists + FROM ( + SELECT CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0) AS size, + ( + SELECT JSON_ARRAYAGG(ctd_name) + FROM $ctTable + JOIN $ctdTable + ON ct_tag_id = ctd_id + WHERE ct_rev_id = revs.rev_id + ) as tags + FROM $revisionTable AS revs + JOIN $pageTable ON revs.rev_page = page_id + $ipcJoin + LEFT JOIN $revisionTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id) + WHERE $whereClause + ORDER BY revs.rev_timestamp DESC + LIMIT 5000 + ) data"; $results = $this->executeProjectsQuery( $project, $sql, $params )->fetchAssociative(); - $results['sizes'] = json_decode( $results['sizes'] ?? '[]' ); + $results['tag_lists'] = json_decode( $results['tag_lists'] ); + $results['sizes'] = json_decode( $results['sizes'] ); $results['average_size'] = count( $results['sizes'] ) > 0 ? array_sum( $results['sizes'] ) / count( $results['sizes'] ) : 0; diff --git a/src/Repository/PageRepository.php b/src/Repository/PageRepository.php index f5e07203a..cb8f9621e 100644 --- a/src/Repository/PageRepository.php +++ b/src/Repository/PageRepository.php @@ -100,7 +100,6 @@ public function getPagesWikitext( Project $project, array $pageTitles ): array { * @param false|int $start * @param false|int $end * @param int|null $limit - * @param int|null $numRevisions * @return string[] Each member with keys: id, timestamp, length, * minor, length_change, user_id, username, comment, sha, deleted, tags. */ @@ -110,14 +109,13 @@ public function getRevisions( false|int $start = false, false|int $end = false, ?int $limit = null, - ?int $numRevisions = null ): array { $cacheKey = $this->getCacheKey( func_get_args(), 'page_revisions' ); if ( $this->cache->hasItem( $cacheKey ) ) { return $this->cache->getItem( $cacheKey )->get(); } - $stmt = $this->getRevisionsStmt( $page, $user, $limit, $numRevisions, $start, $end ); + $stmt = $this->getRevisionsStmt( $page, $user, $limit, $start, $end ); $result = $stmt->fetchAllAssociative(); // Cache and return. @@ -129,9 +127,6 @@ public function getRevisions( * @param Page $page The page. * @param User|null $user Specify to get only revisions by the given user. * @param ?int $limit Max number of revisions to process. - * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the - * OFFSET if we are given a $limit (see below). If $limit is set and $numRevisions is not set, - * a separate query is ran to get the number of revisions. * @param false|int $start * @param false|int $end * @return Result @@ -140,7 +135,6 @@ public function getRevisionsStmt( Page $page, ?User $user = null, ?int $limit = null, - ?int $numRevisions = null, false|int $start = false, false|int $end = false ): Result { diff --git a/src/Repository/PagesRepository.php b/src/Repository/PagesRepository.php index 704fdfe76..86df05d14 100644 --- a/src/Repository/PagesRepository.php +++ b/src/Repository/PagesRepository.php @@ -48,17 +48,26 @@ public function countPagesCreated( $conditions = array_merge( $conditions, $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), - $this->getUserConditions( ( $start . $end ) !== '' ) + $this->getUserConditions( ( $start . $end ) !== '' ), + $this->getPrpConditions( $namespace, $project ) ); $wasRedirect = $this->getWasRedirectClause( $redirects, $deleted ); $summation = Pages::DEL_NONE !== $deleted ? 'redirect OR was_redirect' : 'redirect'; + $prpSelect = ''; + if ( $project->isPrpPage( $namespace ) ) { + foreach ( [ 0, 1, 2, 3, 4 ] as $level ) { + $prpSelect .= ", SUM(IF(`prp_quality` = $level, 1, 0)) AS `prp_quality$level`"; + } + } + $sql = "SELECT `namespace`, COUNT(page_title) AS `count`, SUM(IF(type = 'arc', 1, 0)) AS `deleted`, SUM($summation) AS `redirects`, SUM(rev_length) AS `total_length` + $prpSelect FROM (" . $this->getPagesCreatedInnerSql( $project, $conditions, $deleted, $start, $end, false, true ) . " ) a " . @@ -111,7 +120,8 @@ public function getPagesCreated( $conditions = array_merge( $conditions, $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), - $this->getUserConditions( $start . $end !== '' ) + $this->getUserConditions( $start . $end !== '' ), + $this->getPrpConditions( $namespace, $project ) ); $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); @@ -131,7 +141,7 @@ public function getPagesCreated( ON pa_project_id = pap_project_id WHERE pa_page_id = page_id ) AS pap_project_title"; - $conditions['paSelectsArchive'] = ', NULL AS pa_class, NULL as pap_project_title'; + $conditions['paSelectsArchive'] = ', NULL AS pa_class, NULL AS pap_project_title'; $conditions['revPageGroupBy'] = 'GROUP BY rev_page'; } @@ -190,6 +200,28 @@ private function getNamespaceRedirectAndDeletedPagesConditions( string|int $name return $conditions; } + /** + * Get SQL fragments for ProofreadPage quality. + * @param int|string $namespace + * @param Project $project + * @return string[] With keys 'prpSelect', 'prpArSelect' and 'prpJoin' + */ + private function getPrpConditions( int|string $namespace, Project $project ): array { + $conditions = [ + 'prpSelect' => '', + 'prpArSelect' => '', + 'prpJoin' => '', + ]; + if ( $project->isPrpPage( $namespace ) ) { + $pagePropsTable = $project->getTableName( 'page_props', '' ); + $conditions['prpSelect'] = ", pp_value AS `prp_quality`"; + $conditions['prpArSelect'] = ", NULL AS `prp_quality`"; + $conditions['prpJoin'] = "LEFT OUTER JOIN $pagePropsTable + ON (pp_page, pp_propname) = (page_id, 'proofread_page_quality_level')"; + } + return $conditions; + } + /** * Inner SQL for getting or counting pages created by the user. * @param Project $project @@ -218,7 +250,7 @@ private function getPagesCreatedInnerSql( $logTable = $project->getTableName( 'logging', 'logindex' ); // Only SELECT things that are needed, based on whether or not we're doing a COUNT. - $revSelects = "DISTINCT page_namespace AS `namespace`, 'rev' AS `type`, page_title, " + $revSelects = "page_namespace AS `namespace`, 'rev' AS `type`, page_title, " . "page_is_redirect AS `redirect`, rev_len AS `rev_length`"; if ( !$count ) { $revSelects .= ", page_len AS `length`, rev_timestamp AS `timestamp`, " @@ -232,10 +264,11 @@ private function getPagesCreatedInnerSql( $tagDefTable = $project->getTableName( 'change_tag_def' ); $revisionsSelect = " - SELECT $revSelects " . $conditions['paSelects'] . ", + SELECT $revSelects " . $conditions['paSelects'] . $conditions['prpSelect'] . ", NULL AS was_redirect FROM $pageTable JOIN $revisionTable ON page_id = rev_page + " . $conditions['prpJoin'] . " WHERE " . $conditions['whereRev'] . " AND rev_parent_id = '0'" . $conditions['namespaceRev'] . @@ -256,7 +289,7 @@ private function getPagesCreatedInnerSql( } $archiveSelect = " - SELECT $arSelects " . $conditions['paSelectsArchive'] . ", + SELECT $arSelects " . $conditions['paSelectsArchive'] . $conditions['prpArSelect'] . ", ( SELECT 1 FROM $tagTable diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 78603d61e..ae5ad6bf5 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -253,6 +253,7 @@ public function getMetadata( string $projectUrl ): ?array { $metadata = [ 'general' => [], 'namespaces' => [], + 'canonical_namespaces' => [], 'tempAccountPatterns' => $res['query']['autocreatetempuser']['matchPatterns'] ?? null, ]; @@ -300,10 +301,13 @@ private function setNamespaces( array $res, array &$metadata ): void { } elseif ( isset( $namespace['*'] ) ) { $name = $namespace['*']; } else { - continue; + $name = null; } - $metadata['namespaces'][$namespace['id']] = $name; + if ( $name !== null ) { + $metadata['namespaces'][$namespace['id']] = $name; + } + $metadata['canonical_namespaces'][$namespace['id']] = $namespace['canonical'] ?? ''; } } @@ -351,6 +355,10 @@ public function pageHasContent( Project $project, int $namespaceId, string $page * @return string[] */ public function getInstalledExtensions( Project $project ): array { + $cacheKey = $this->getCacheKey( func_get_args(), "project_extensions" ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } $res = json_decode( $this->guzzle->request( 'GET', $project->getApiUrl(), [ 'query' => [ 'action' => 'query', 'meta' => 'siteinfo', @@ -359,9 +367,37 @@ public function getInstalledExtensions( Project $project ): array { ] ] )->getBody()->getContents(), true ); $extensions = $res['query']['extensions'] ?? []; - return array_map( static function ( $extension ) { + // Cache for one hour and return. + return $this->setCache( $cacheKey, array_map( static function ( $extension ) { return $extension['name']; - }, $extensions ); + }, $extensions ), 'PT1H' ); + } + + /** + * Get the list of the names for each ProofreadPage quality + * on this wiki. Keys are 0, 1, 2, 3, and 4. + * @param Project $project + * @return string[] + */ + public function getPrpQualityNames( Project $project ): array { + $cacheKey = $this->getCacheKey( func_get_args(), "project_prp_levels" ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $res = json_decode( $this->guzzle->request( 'GET', $project->getApiUrl(), [ 'query' => [ + 'action' => 'query', + 'meta' => 'proofreadinfo', + 'prpiprop' => 'qualitylevels', + 'format' => 'json', + ] ] )->getBody()->getContents(), true ); + + $qualityLevels = $res['query']['proofreadqualitylevels'] ?? []; + + // Cache for one week (will change extremely rarely) and return. + return $this->setCache( $cacheKey, array_map( static function ( $level ) { + return $level['category']; + }, $qualityLevels ), 'P1W' ); } /** diff --git a/src/Repository/TopEditsRepository.php b/src/Repository/TopEditsRepository.php index 65aa35c48..05147dfed 100644 --- a/src/Repository/TopEditsRepository.php +++ b/src/Repository/TopEditsRepository.php @@ -59,6 +59,61 @@ public function getEdit( Page $page, array $revision ): Edit { return new Edit( $this->editRepo, $this->userRepo, $page, $revision ); } + /** + * Get the selects for PageAssessments class and projects. + * @param Project $project + * @param int|string $namespace + * @return string + */ + private function getPaSelects( + Project $project, + $namespace + ): string { + $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); + $paTable = $project->getTableName( 'page_assessments' ); + $paSelect = $hasPageAssessments + ? ", ( + SELECT pa_class + FROM $paTable + WHERE pa_page_id = page_id + AND pa_class != '' + LIMIT 1 + ) AS pa_class" + : ''; + $paProjectsTable = $project->getTableName( 'page_assessments_projects' ); + $paProjectsSelect = $hasPageAssessments + ? ", ( + SELECT JSON_ARRAYAGG(pap_project_title) + FROM $paTable + JOIN $paProjectsTable + ON pa_project_id = pap_project_id + WHERE pa_page_id = page_id + ) AS pap_project_title" + : ''; + return $paSelect . $paProjectsSelect; + } + + /** + * Get the select and join for ProofreadPage quality level. + * @param Project $project + * @param int|string $namespace Namespade ID or 'all' + * @return array With keys 'prpSelect' and 'prpJoin' + */ + private function getPrpConditions( + Project $project, + $namespace + ): array { + if ( $project->isPrpPage( $namespace ) ) { + $pagePropsTable = $project->getTableName( 'page_props', '' ); + return [ + 'prpSelect' => ", pp_value as `prp_quality`", + 'prpJoin' => "LEFT OUTER JOIN $pagePropsTable + ON (pp_page, pp_propname) = (page_id, 'proofread_page_quality_level')", + ]; + } + return [ 'prpSelect' => '', 'prpJoin' => '' ]; + } + /** * Get the top edits by a user in a single namespace. * @param Project $project @@ -89,18 +144,6 @@ public function getTopEditsNamespace( $pageTable = $project->getTableName( 'page' ); $revisionTable = $project->getTableName( 'revision' ); - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); - $paTable = $project->getTableName( 'page_assessments' ); - $paSelect = $hasPageAssessments - ? ", ( - SELECT pa_class - FROM $paTable - WHERE pa_page_id = page_id - AND pa_class != '' - LIMIT 1 - ) AS pa_class" - : ''; - $ipcJoin = ''; $whereClause = 'rev_actor = :actorId'; $params = []; @@ -111,13 +154,18 @@ public function getTopEditsNamespace( [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); } + $paSelects = $this->getPaSelects( $project, $namespace ); + + $prpConditions = $this->getPrpConditions( $project, $namespace ); + $offset = $pagination * $limit; $sql = "SELECT page_namespace AS `namespace`, page_title, page_is_redirect AS `redirect`, COUNT(page_title) AS `count` - $paSelect + $paSelects + " . $prpConditions['prpSelect'] . " FROM $pageTable - JOIN $revisionTable ON page_id = rev_page + " . $prpConditions['prpJoin'] . " $ipcJoin WHERE $whereClause AND page_namespace = :namespace @@ -271,17 +319,6 @@ public function getTopEditsAllNamespaces( $revDateConditions = $this->getDateConditions( $start, $end ); $pageTable = $this->getTableName( $project->getDatabaseName(), 'page' ); $revisionTable = $this->getTableName( $project->getDatabaseName(), 'revision' ); - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments(); - $pageAssessmentsTable = $this->getTableName( $project->getDatabaseName(), 'page_assessments' ); - $paSelect = $hasPageAssessments - ? ", ( - SELECT pa_class - FROM $pageAssessmentsTable - WHERE pa_page_id = e.page_id - AND pa_class != '' - LIMIT 1 - ) AS pa_class" - : ''; $ipcJoin = ''; $whereClause = 'rev_actor = :actorId'; @@ -293,28 +330,33 @@ public function getTopEditsAllNamespaces( [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); } - $sql = "SELECT c.page_namespace AS `namespace`, e.page_title, - c.page_is_redirect AS `redirect`, c.count $paSelect - FROM - ( - SELECT b.page_namespace, b.page_is_redirect, b.rev_page, b.count - ,@rn := if(@ns = b.page_namespace, @rn + 1, 1) AS `row_number` - ,@ns := b.page_namespace AS dummy - FROM - ( - SELECT page_namespace, page_is_redirect, rev_page, count(rev_page) AS count - FROM $revisionTable - $ipcJoin - JOIN $pageTable ON page_id = rev_page - WHERE $whereClause - $revDateConditions - GROUP BY page_namespace, rev_page - ) AS b - JOIN (SELECT @ns := NULL, @rn := 0) AS vars - ORDER BY b.page_namespace ASC, b.count DESC - ) AS c - JOIN $pageTable e ON e.page_id = c.rev_page - WHERE c.`row_number` <= $limit"; + $paSelects = $this->getPaSelects( $project, 'all' ); + $prpConditions = $this->getPrpConditions( $project, 'all' ); + + $sql = " + SELECT * FROM ( + SELECT + page_namespace as `namespace`, + page_title, + page_is_redirect as `redirect`, + rev_page, + count(rev_page) AS `count`, + ROW_NUMBER() OVER ( + PARTITION BY page_namespace + ORDER BY page_namespace ASC, `count` DESC + ) `row_number` + $paSelects + " . $prpConditions['prpSelect'] . " + FROM $revisionTable + $ipcJoin + JOIN $pageTable ON page_id = rev_page + " . $prpConditions['prpJoin'] . " + WHERE $whereClause + $revDateConditions + GROUP BY page_namespace, rev_page + ) a + WHERE `row_number` <= $limit + ORDER BY `namespace` ASC, `count` DESC"; $resultQuery = $this->executeQuery( $sql, $project, $user, 'all', $params ); $result = $resultQuery->fetchAllAssociative(); diff --git a/templates/editCounter/general_stats.html.twig b/templates/editCounter/general_stats.html.twig index 19de77b62..92f587224 100644 --- a/templates/editCounter/general_stats.html.twig +++ b/templates/editCounter/general_stats.html.twig @@ -182,20 +182,20 @@ {{ msg('small-edits') }}* - {{ ec.countSmallEdits|num_format }} + {{ ec.editData.small_edits|num_format }} {% if ec.countLast5000 %} · - ({{ ((ec.countSmallEdits / ec.countLast5000) * 100)|percent_format }}) + ({{ ((ec.editData.small_edits / ec.countLast5000) * 100)|percent_format }}) {% endif %} {{ msg('large-edits') }}* - {{ ec.countLargeEdits|num_format }} + {{ ec.editData.large_edits|num_format }} {% if ec.countLast5000 %} · - ({{ ((ec.countLargeEdits / ec.countLast5000) * 100)|percent_format }}) + ({{ ((ec.editData.small_edits / ec.countLast5000) * 100)|percent_format }}) {% endif %} @@ -507,6 +507,7 @@ {% if ec.countLiveRevisions %}
+ {### Deleted edits ###} {% if not(user.isIpRange) %} {{ chart.pie_chart('deleted_edits', @@ -523,6 +524,7 @@ ) }} {% endif %} + {### Edit size histogram ###}
{{ msg('num-edits-size-interval') }}*
@@ -531,12 +533,34 @@ + {### ProofreadPage quality changes ###} + {% if project.hasProofreadPage %} + {% set qualityChanges = ec.countQualityChanges %} + {% set qualityNames = project.prpQualityNames %} + {% if qualityChanges.total > 1 %} +
+ {{ msg('proofreadpage-qualitychanges') }}* + {{ + chart.pie_chart('prp_qualitychanges', + [0, 1, 2, 3, 4]|map((i) => { + label: qualityNames[i], + value: qualityChanges[i], + percentage: ((qualityChanges[i] / qualityChanges.total) * 100), + color: color([11, 0, 4, 2, 1][i]) + }), + true, + 'qualitychangechart' + ) + }} +
+ {% endif %} + {% endif %}
* {{ msg('data-limit', [5000, 5000|num_format]) }}
diff --git a/templates/editCounter/timecard.html.twig b/templates/editCounter/timecard.html.twig index 74e48ea20..c9d90e05a 100644 --- a/templates/editCounter/timecard.html.twig +++ b/templates/editCounter/timecard.html.twig @@ -64,7 +64,7 @@ {% for i in 0..6 %} { backgroundColor: "{{ chartColor(i) }}", - data: {{ ec.timeCard|slice(i*24, 24)|json_encode()|raw }} + data: {{ ec.timecard|slice(i*24, 24)|json_encode()|raw }} }, {% endfor %} ]; diff --git a/templates/macros/pieChart.html.twig b/templates/macros/pieChart.html.twig index 727ab2601..ad9298072 100644 --- a/templates/macros/pieChart.html.twig +++ b/templates/macros/pieChart.html.twig @@ -9,10 +9,10 @@ {% for entry in data %} {% set labels = labels | merge([entry.label]) %} {% set values = values | merge([entry.value]) %} - {% set colors = colors | merge([chartColor(loop.index0)]) %} + {% set colors = colors | merge([entry.color ?? chartColor(loop.index0)]) %} {% if legend %}
- + {{ entry.label }} · diff --git a/templates/pages/_pages_list.html.twig b/templates/pages/_pages_list.html.twig index 360c1bf9d..f9ddffb32 100644 --- a/templates/pages/_pages_list.html.twig +++ b/templates/pages/_pages_list.html.twig @@ -1,5 +1,6 @@ {% import 'macros/layout.html.twig' as layout %} {% import 'macros/wiki.html.twig' as wiki %} +{% import 'macros/pieChart.html.twig' as chart %} {% set content %} {% if pages.getNumPages == 0 %} @@ -25,119 +26,157 @@ [{{ msg('hide')|lower }}] - - - - {% set columns = ['page-title', 'date', 'original-size'] %} - {% if pages.deleted != 'deleted' %} - {% set columns = columns|merge(['current-size']) %} - {% endif %} - {% if project.hasPageAssessments(ns) and pages.deleted != 'deleted' %} - {% set columns = columns|merge(['assessment']) %} - {% endif %} - {% for thKey in columns %} - - {% endfor %} - - - - {% set index = 0 %} - {% for page in pages.results[ns] %} - {% set index = index + 1 %} - {% set pagename = titleWithNs(page.page_title, ns, project.namespaces) %} - - - - - +
+ +
# - - {{ msg(thKey)|ucfirst }} - - - {{ msg('links') }}
{{ index|num_format }} - {% if page.redirect %} - {{ wiki.pageLinkRaw(pagename, project, pagename, 'redirect=no') }} - · ({{ msg('redirect') }}) - {% else %} - {{ wiki.pageLinkRaw(pagename, project) }} - {% endif %} - {% if page.deleted %} - · - - ({{ msg('deleted') }}) -
- {{ msg('loading') }}... -
-
- {% if page.recreated is defined and page.recreated %} - · ({{ msg('recreated') }}) - {% endif %} - {% endif %} -
- {{ page.rev_length|num_format }} -
+ + + {% set columns = ['page-title', 'date', 'original-size'] %} {% if pages.deleted != 'deleted' %} - + {% endfor %} + + + + {% set index = 0 %} + {% for page in pages.results[ns] %} + {% set index = index + 1 %} + {% set pagename = titleWithNs(page.page_title, ns, project.namespaces) %} + + + - {% if project.hasPageAssessments(ns) and page.assessment is defined %} - + + {% if pages.deleted != 'deleted' %} + - {% endif %} - {% endif %} - {% endif %} - {% if enabled('TopEdits') %} - · - {{ msg('tool-topedits') }} + {% if page.prp_quality is defined %} + {% endif %} - {% if isWMF() %} + {% endif %} + - - {% endfor %} - {% if pages.multiNamespace and pages.counts[ns].count > pages.resultsPerPage %} - - - - + + + {% endfor %} + {% if pages.multiNamespace and pages.counts[ns].count > pages.resultsPerPage %} + + + + + {% endif %} + +
# - {% if page.length is null %} - {{ msg('na') }} + {% set columns = columns|merge(['current-size']) %} + {% endif %} + {% if project.hasPageAssessments(ns) and pages.deleted != 'deleted' %} + {% set columns = columns|merge(['assessment']) %} + {% endif %} + {% if project.isPrpPage(ns) %} + {% set columns = columns|merge(['proofreadpage-quality']) %} + {% set qualityNames = project.prpQualityNames %} + {% endif %} + {% for thKey in columns %} + + + {{ msg(thKey)|ucfirst }} + + + {{ msg('links') }}
{{ index|num_format }} + {% if page.redirect %} + {{ wiki.pageLinkRaw(pagename, project, pagename, 'redirect=no') }} + · ({{ msg('redirect') }}) {% else %} - {{ page.length|num_format }} + {{ wiki.pageLinkRaw(pagename, project) }} + {% endif %} + {% if page.deleted %} + · + + ({{ msg('deleted') }}) +
+ {{ msg('loading') }}... +
+
+ {% if page.recreated is defined and page.recreated %} + · ({{ msg('recreated') }}) + {% endif %} {% endif %}
- {% if page.deleted %} + + {{ page.rev_length|num_format }} + + {% if page.length is null %} {{ msg('na') }} {% else %} - {% if page.assessment.badge is defined %} - {{ page.assessment.class }} - {% endif %} - {{ page.assessment.class ? page.assessment.class|ucfirst : msg('unknown') }} + {{ page.length|num_format }} {% endif %} - {{ wiki.pageLogLinkRaw(pagename, project) }} - {% if not(page.deleted) or (page.recreated is defined and page.recreated == true) %} - · - {{ wiki.pageHistLinkRaw(pagename, project) }} - {% if enabled('PageInfo') %} - · - {{ msg('tool-pageinfo') }} + {% if project.hasPageAssessments(ns) and page.assessment is defined %} + + {% if page.deleted %} + {{ msg('na') }} + {% else %} + {% if page.assessment.badge is defined %} + {{ page.assessment.class }} + {% endif %} + {{ page.assessment.class ? page.assessment.class|ucfirst : msg('unknown') }} + {% endif %} + + {% if page.deleted %} + {{ msg('na') }} + {% else %} + + {{ qualityNames[page.prp_quality] }} + + {% endif %} + + {{ wiki.pageLogLinkRaw(pagename, project) }} + {% if not(page.deleted) or (page.recreated is defined and page.recreated == true) %} · - - {{ msg('pageviews') }} - + {{ wiki.pageHistLinkRaw(pagename, project) }} + {% if enabled('PageInfo') %} + · + {{ msg('tool-pageinfo') }} + {% endif %} + {% if enabled('TopEdits') %} + · + {{ msg('tool-topedits') }} + {% endif %} + {% if isWMF() %} + · + + {{ msg('pageviews') }} + + {% endif %} {% endif %} - {% endif %} -
- - {{ (pages.counts[ns].count - pages.resultsPerPage)|number_format }} {{ msg('num-others', [pages.counts[ns].count - pages.resultsPerPage]) }} - -
+ + {{ (pages.counts[ns].count - pages.resultsPerPage)|number_format }} {{ msg('num-others', [pages.counts[ns].count - pages.resultsPerPage]) }} + +
+ + {% if project.isPrpPage(ns) %} + {% set qualityNames = project.prpQualityNames %} +
+ {{ msg('proofreadpage-quality') }} + {{ + chart.pie_chart('prp_quality', + [0, 1, 2, 3, 4]|map((i) => { + label: qualityNames[i], + value: pages.counts[ns]['prp_quality' ~ i], + percentage: ((pages.counts[ns]['prp_quality' ~ i] / pages.counts[ns].count) * 100), + color: color([11, 0, 4, 2, 1][i]) + }), + true, + 'qualitychart' + ) + }} +
{% endif %} - - + +
{% endfor %} {##### PAGINATION #####} diff --git a/templates/pages/result.csv.twig b/templates/pages/result.csv.twig index cfea841a5..427e16369 100644 --- a/templates/pages/result.csv.twig +++ b/templates/pages/result.csv.twig @@ -5,6 +5,10 @@ {% if project.hasPageAssessments and pages.deleted != 'deleted' %} {% set columns = columns|merge(['assessment']) %} {% endif %} +{% if project.hasProofreadPage and pages.deleted != 'deleted' %} +{% set columns = columns|merge(['proofreadpage-quality']) %} +{% set qualityNames = project.prpQualityNames %} +{% endif %} {% for thKey in columns %} {{ msg(thKey) }}{% if not loop.last %},{% endif %} {% endfor %} @@ -12,7 +16,7 @@ {% for ns in pages.results|keys %} {% for page in pages.results[ns] %} {% set pageTitle = titleWithNs(page.page_title, ns, project.namespaces) %} -{{ ns }},"{{ pageTitle }}",{{ page.timestamp|date_format }},{{ page.rev_length }}{% if pages.deleted != 'deleted' %},{% if page.length is not null %}{{ page.length }}{% endif %}{% endif %}{% if project.hasPageAssessments %},{{ page.assessment.class ? page.assessment.class|ucfirst : msg('unknown') }}{% endif %} +{{ ns }},"{{ pageTitle }}",{{ page.timestamp|date_format }},{{ page.rev_length }}{% if pages.deleted != 'deleted' %},{% if page.length is not null %}{{ page.length }}{% endif %}{% endif %}{% if project.hasPageAssessments %},{{ page.assessment.class ? page.assessment.class|ucfirst : msg('unknown') }}{% endif %}{% if project.hasProofreadPage %},{{ page.prp_quality ? qualityNames[page.prp_quality] : '' }}{% endif %} {% endfor %} {% endfor %} diff --git a/templates/pages/result.tsv.twig b/templates/pages/result.tsv.twig index f9c1f89cd..f69e0be0f 100644 --- a/templates/pages/result.tsv.twig +++ b/templates/pages/result.tsv.twig @@ -5,6 +5,10 @@ {% if project.hasPageAssessments and pages.deleted != 'deleted' %} {% set columns = columns|merge(['assessment']) %} {% endif %} +{% if project.hasProofreadPage and pages.deleted != 'deleted' %} +{% set columns = columns|merge(['proofreadpage-quality']) %} +{% set qualityNames = project.prpQualityNames %} +{% endif %} {% for thKey in columns %} {{ msg(thKey) }}{% if not loop.last %} {% endif %} {% endfor %} @@ -12,7 +16,7 @@ {% for ns in pages.results|keys %} {% for page in pages.results[ns] %} {% set pageTitle = titleWithNs(page.page_title, ns, project.namespaces) %} -{{ ns }} {{ pageTitle }} {{ page.timestamp|date_format }} {{ page.rev_length }}{% if pages.deleted != 'deleted' %} {% if page.length is not null %}{{ page.length }}{% endif %}{% endif %}{% if project.hasPageAssessments %} {{ page.assessment.class ? page.assessment.class|ucfirst : msg('unknown') }}{% endif %} +{{ ns }} {{ pageTitle }} {{ page.timestamp|date_format }} {{ page.rev_length }}{% if pages.deleted != 'deleted' %} {% if page.length is not null %}{{ page.length }}{% endif %}{% endif %}{% if project.hasPageAssessments %} {{ page.assessment.class ? page.assessment.class|ucfirst : msg('unknown') }}{% endif %}{% if project.hasProofreadPage %} {{ page.prp_quality ? qualityNames[page.prp_quality] : '' }}{% endif %} {% endfor %} {% endfor %} diff --git a/templates/pages/result.wikitext.twig b/templates/pages/result.wikitext.twig index 467e38f19..b37849603 100644 --- a/templates/pages/result.wikitext.twig +++ b/templates/pages/result.wikitext.twig @@ -19,6 +19,10 @@ {% if project.hasPageAssessments(ns) and pages.deleted != 'deleted' %} {% set columns = columns|merge(['assessment']) %} {% endif %} +{% if project.isPrpPage(ns) %} +{% set columns = columns|merge(['proofreadpage-quality']) %} +{% set qualityNames = project.prpQualityNames %} +{% endif %} {% for thKey in columns %} ! {{ msg(thKey)|ucfirst }} {% endfor %} @@ -42,6 +46,9 @@ [[File:{{ project.pageAssessments.badgeURL(page.assessment.class, true) }}|20px]] {{ page.assessment.class ? page.assessment.class : msg('unknown') }} {% endif %} {% endif %} +{% if page.prp_quality is defined %} +| {{ qualityNames[page.prp_quality] }} +{% endif %} {% endif %} | [{{ wiki.pageLogUrlRaw(pageTitle, project) }} {{ msg('log') }}]{% if not(page.deleted) %} · [{{ wiki.pageHistUrlRaw(pageTitle, project) }} {{ msg('history') }}]{% if enabled('PageInfo') %} · [{{ url('PageInfoResult', {'project': project.domain, 'page': pageTitle}) }} {{ msg('tool-pageinfo') }}]{% endif %}{% if enabled('TopEdits') %} · [{{ url('topedits', {'project': project.domain, 'username': user.usernameIdent, 'namespace': ns, 'page': page.page_title}) }} {{ msg('tool-topedits') }}]{% endif %}{% if isWMF() %} · [https://pageviews.wmcloud.org/?project={{ project.domain }}&pages={{ pageTitle|e('url') }} {{ msg('pageviews') }}]{% endif %}{% endif %} diff --git a/templates/topedits/result_namespace.html.twig b/templates/topedits/result_namespace.html.twig index e2a17331b..fa02c2417 100644 --- a/templates/topedits/result_namespace.html.twig +++ b/templates/topedits/result_namespace.html.twig @@ -79,6 +79,15 @@ {% endif %} + {% if project.isPrpPage(ns) %} + + + {{ msg('proofreadpage-quality') }} + + + + {% set qualityNames = project.prpQualityNames %} + {% endif %} {{ msg('links') }} @@ -100,6 +109,13 @@ {{ page.assessment.class ? page.assessment.class : msg('unknown') }} {% endif %} + {% if project.isPrpPage(ns) %} + + + {{ qualityNames[page.prp_quality] }} + + + {% endif %} {{ wiki.pageLogLinkRaw(pageTitle, project) }} · diff --git a/templates/topedits/result_namespace.wikitext.twig b/templates/topedits/result_namespace.wikitext.twig index 674b8e906..0c6f2ea7e 100644 --- a/templates/topedits/result_namespace.wikitext.twig +++ b/templates/topedits/result_namespace.wikitext.twig @@ -15,6 +15,7 @@ ==== {{ nsName(ns, project.namespaces) }} ==== {% set showPageAssessment = ns == 0 and project.hasPageAssessments %} +{% set showProofreadQuality = project.hasProofreadPage and project.isPrpPage(ns) %} {{ te.numPagesNamespace(ns) }} {{ msg('pages')|lower }}. {| class="wikitable sortable" ! {{ msg('edits')|ucfirst }} @@ -22,6 +23,9 @@ {% if showPageAssessment %} ! {{ msg('assessment') }} {% endif %} +{% if showProofreadQuality %} +! {{ msg('proofreadpage-quality') }}{% set qualityNames = project.prpQualityNames %} +{% endif %} ! {{ msg('links') }} {% for page in pages %} |- @@ -32,6 +36,9 @@ {% if badge is defined %} [[File:{{ badge }}|20px]] {{ page.assessment.class ? page.assessment.class : msg('unknown') }} {% endif %}{% endif %} +{% if showProofreadQuality %} +| {{ qualityNames[page.prp_quality] }} +{% endif %} | [{% verbatim %}{{{% endverbatim %}fullurl:Special:Log|page={{ page.full_page_title|replace({' ': '_'}) }}}} {{ msg('log') }}] · [{{ url('PageInfoResult', {project:project.domain, page:page.full_page_title}) }} {{ msg('tool-pageinfo') }}] · [{{ url('TopEditsResultPage', {project:project.domain, username:user.usernameIdent, namespace:page.namespace, page:page.page_title}) }} {{ msg('tool-topedits') }}] {% endfor %} {% if pages|length >= 10 and te.topEdits|length > 1 %} diff --git a/tests/Exception/XtoolsHttpExceptionTest.php b/tests/Exception/XtoolsHttpExceptionTest.php new file mode 100644 index 000000000..4909dbe26 --- /dev/null +++ b/tests/Exception/XtoolsHttpExceptionTest.php @@ -0,0 +1,20 @@ +getRedirectUrl() ); + static::assertEquals( [], $exception->getParams() ); + static::assertFalse( $exception->isApi() ); + } +} diff --git a/tests/Model/AdminStatsTest.php b/tests/Model/AdminStatsTest.php index 94362cf12..00bce4bed 100644 --- a/tests/Model/AdminStatsTest.php +++ b/tests/Model/AdminStatsTest.php @@ -7,7 +7,6 @@ use App\Model\AdminStats; use App\Model\Project; use App\Repository\AdminStatsRepository; -use App\Repository\ProjectRepository; use App\Tests\TestAdapter; /** @@ -15,6 +14,7 @@ * @covers \App\Model\AdminStats */ class AdminStatsTest extends TestAdapter { + protected AdminStatsRepository $asRepo; protected Project $project; protected ProjectRepository $projectRepo; @@ -33,11 +33,32 @@ public function setUp(): void { $this->asRepo = $this->createMock( AdminStatsRepository::class ); // This logic is tested with integration tests. - // Here we just stub empty arrays so AdminStats won't error outl. + // Here we just stub empty arrays so AdminStats won't error out. $this->asRepo->method( 'getUserGroups' ) ->willReturn( [ 'local' => [], 'global' => [] ] ); } + /** + * Test icons for each group. + * Note that this should be independant from everything, + * and so is static. + */ + public function testGroupIcons(): void { + $this->asRepo->expects( static::once() ) + ->method( 'getUserGroupIcons' ) + ->willReturn( [ + 'sysop' => 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Mop.svg/18px-Mop.svg.png', + ] ); + $as = new AdminStats( $this->asRepo, $this->project, 0, 0, 'admin', [] ); + static::assertEquals( [ + 'sysop' => 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Mop.svg/18px-Mop.svg.png', + ], $as->getUserGroupIcons() ); + // Test for wikitext; also implictly ensure we cache + static::assertEquals( [ + 'sysop' => 'Mop.svg', + ], $as->getUserGroupIcons( true ) ); + } + /** * Basic getters. */ @@ -59,8 +80,10 @@ public function testBasics(): void { static::assertEquals( 1483228800, $as->getStart() ); static::assertEquals( 1488326400, $as->getEnd() ); static::assertEquals( 60, $as->numDays() ); + static::assertEquals( 'admin', $as->getType() ); static::assertSame( 1, $as->getNumInRelevantUserGroup() ); - static::assertSame( 1, $as->getNumWithActionsNotInGroup() ); + static::assertSame( 1, $as->getNumWithActions() ); + static::assertEquals( 2, $as->getNumWithActionsNotInGroup() ); } /** @@ -80,6 +103,8 @@ public function testAdminsAndGroups(): void { ], $as->getUsersAndGroups() ); + // Ensure we cache + $as->getUsersAndGroups(); } /** @@ -95,22 +120,34 @@ public function testStats(): void { // Test results. static::assertEquals( - [ - 'Bob' => array_merge( - $this->adminStatsFactory()[0], - [ 'user-groups' => [ 'sysop', 'checkuser' ] ] - ), - 'Sarah' => array_merge( - // empty results - $this->adminStatsFactory()[1], - [ 'username' => 'Sarah', 'user-groups' => [ 'epcoordinator' ] ] - ), - ], - $ret + array_merge( + $this->adminStatsFactory()[0], + [ 'user-groups' => [ 'sysop', 'checkuser' ] ] + ), + $ret['Bob'] + ); + static::assertEquals( + array_merge( + // empty results + $this->adminStatsFactory()[1], + [ 'username' => 'Sarah', 'user-groups' => [ 'epcoordinator' ] ] + ), + $ret['Sarah'] ); // At this point get stats should be the same. static::assertEquals( $ret, $as->getStats() ); + + static::assertEquals( [ + 'delete', + 'restore', + 'block', + 'unblock', + 'protect', + 'unprotect', + 'rights', + 'import', + ], array_values( $as->getActions() ) ); } /** @@ -140,6 +177,18 @@ private function adminStatsFactory(): array { 'unprotect' => 0, 'rights' => 0, 'import' => 0, + 'total' => 20, + ], + [ + 'username' => 'Alice', + 'delete' => 0, + 'restore' => 0, + 'block' => 0, + 'unblock' => 0, + 'protect' => 0, + 'unprotect' => 0, + 'rights' => 0, + 'import' => 0, 'total' => 0, ], ]; @@ -157,15 +206,15 @@ public function testTotalsRow(): void { $as->prepareStats(); static::assertEquals( [ - 'delete' => 5 + 1, - 'restore' => 3 + 0, - 'block' => 0 + 0, - 'unblock' => 1 + 0, - 'protect' => 3 + 0, - 'unprotect' => 2 + 0, - 'rights' => 4 + 0, - 'import' => 2 + 0, - 'total' => 20 + 0, + 'delete' => 5 + 1 + 0, + 'restore' => 3 + 0 + 0, + 'block' => 0 + 0 + 0, + 'unblock' => 1 + 0 + 0, + 'protect' => 3 + 0 + 0, + 'unprotect' => 2 + 0 + 0, + 'rights' => 4 + 0 + 0, + 'import' => 2 + 0 + 0, + 'total' => 20 + 20 + 0, ], $as->getTotalsRow() ); diff --git a/tests/Model/AuthorshipTest.php b/tests/Model/AuthorshipTest.php index 0ad6a27e8..cf158c800 100644 --- a/tests/Model/AuthorshipTest.php +++ b/tests/Model/AuthorshipTest.php @@ -9,8 +9,11 @@ use App\Model\Project; use App\Repository\AuthorshipRepository; use App\Repository\PageRepository; +use App\Repository\ProjectRepository; use App\Tests\TestAdapter; +use GuzzleHttp\Exception\RequestException; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub\Stub; /** * @covers \App\Model\Authorship @@ -22,7 +25,7 @@ class AuthorshipTest extends TestAdapter { public function testAuthorship(): void { /** @var AuthorshipRepository|MockObject $authorshipRepo */ $authorshipRepo = $this->createMock( AuthorshipRepository::class ); - $authorshipRepo->expects( $this->once() ) + $authorshipRepo->expects( $this->exactly( 2 ) ) ->method( 'getData' ) ->willReturn( [ 'revisions' => [ [ @@ -46,17 +49,31 @@ public function testAuthorship(): void { ], ] ], ] ); - $authorshipRepo->expects( $this->once() ) + $authorshipRepo->expects( $this->exactly( 2 ) ) ->method( 'getUsernamesFromIds' ) ->willReturn( [ [ 'user_id' => 1, 'user_name' => 'Mick Jagger' ], [ 'user_id' => 2, 'user_name' => 'Mr. Rogers' ], ] ); - $project = new Project( 'test.example.org' ); + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( static::once() ) + ->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://en.wikipedia.org', + ] ); + $project = new Project( 'en.wikipedia.org' ); + $project->setRepository( $projectRepo ); $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->expects( static::once() ) + ->method( 'getPageInfo' ) + ->willReturn( [ + 'ns' => 0, + ] ); $page = new Page( $pageRepo, $project, 'Test page' ); $authorship = new Authorship( $authorshipRepo, $page, null, 2 ); $authorship->prepareData(); + // Ensure caching + $authorship->prepareData(); static::assertEquals( [ @@ -72,6 +89,8 @@ public function testAuthorship(): void { $authorship->getList() ); + static::assertTrue( $authorship->isSupportedPage( $page ) ); + static::assertNull( $authorship->getError() ); static::assertEquals( 3, $authorship->getTotalAuthors() ); static::assertEquals( 15, $authorship->getTotalCount() ); static::assertEquals( [ @@ -79,5 +98,58 @@ public function testAuthorship(): void { 'percentage' => 20.0, 'numEditors' => 1, ], $authorship->getOthers() ); + static::assertEquals( [ 'id', 'timestamp' ], array_keys( $authorship->getRevision() ) ); + + // Test for a day-only target + $page = $this->createMock( Page::class ); + $page->expects( static::once() ) + ->method( 'getRevisionIdAtDate' ) + ->willReturn( 1234 ); + $authorship = new Authorship( $authorshipRepo, $page, '2001-02-03', 2 ); + static::assertEquals( 1234, $authorship->getTarget() ); + + // Test for a raw ID target and limit null + $authorship = new Authorship( $authorshipRepo, $page, '1234', null ); + static::assertEquals( 1234, $authorship->getTarget() ); + $authorship->prepareData(); + } + + /** + * Test prepareData's reaction to unexpected getData responses + * @dataProvider getDataEdgeCasesProvider + * @param Stub $data + * @param int|null $expected + */ + public function testGetDataEdgeCases( Stub $data, ?int $expected ): void { + $authorshipRepo = $this->createMock( AuthorshipRepository::class ); + $authorshipRepo->expects( static::once() ) + ->method( 'getData' ) + ->will( $data ); + $page = $this->createMock( Page::class ); + $authorship = new Authorship( $authorshipRepo, $page, null ); + $authorship->prepareData(); + static::assertEquals( $expected, $authorship->getTotalAuthors() ); + } + + public function getDataEdgeCasesProvider(): array { + return [ + 'getData requestException' => [ + $this->throwException( $this->createMock( RequestException::class ) ), + null, + ], + 'getData no key revisions' => [ + $this->returnValue( [] ), + null, + ], + 'getData no key tokens' => [ + $this->returnValue( [ + 'revisions' => [ [ '123' => [ + 'time' => '2018-04-16T13:51:11Z', + 'tokens' => [] + ] ] ] + ] ), + 0, + ], + ]; } } diff --git a/tests/Model/AutoEditsTest.php b/tests/Model/AutoEditsTest.php index 1adbe404c..6fcd1c782 100644 --- a/tests/Model/AutoEditsTest.php +++ b/tests/Model/AutoEditsTest.php @@ -115,10 +115,10 @@ public function testGetNonAutomatedEdits(): void { $page, array_merge( $rev, [ 'user' => $this->user ] ) ); - static::assertEquals( $edit, $autoEdits->getNonAutomatedEdits()[0] ); + static::assertEquals( $edit, $autoEdits->getNonAutomatedEdits( false )[0] ); // One more time to ensure things are re-queried. - static::assertEquals( $edit, $autoEdits->getNonAutomatedEdits()[0] ); + static::assertEquals( $edit, $autoEdits->getNonAutomatedEdits( false )[0] ); } /** @@ -191,10 +191,10 @@ public function testGetAutomatedEdits(): void { $page, array_merge( $rev, [ 'user' => $this->user ] ) ); - static::assertEquals( $edit, $autoEdits->getAutomatedEdits()[0] ); + static::assertEquals( $edit, $autoEdits->getAutomatedEdits( false )[0] ); // One more time to ensure things are re-queried. - static::assertEquals( $edit, $autoEdits->getAutomatedEdits()[0] ); + static::assertEquals( $edit, $autoEdits->getAutomatedEdits( false )[0] ); } /** diff --git a/tests/Model/BlameTest.php b/tests/Model/BlameTest.php index b64bc6b11..968c81b15 100644 --- a/tests/Model/BlameTest.php +++ b/tests/Model/BlameTest.php @@ -5,11 +5,13 @@ namespace App\Tests\Model; use App\Model\Blame; +use App\Model\Edit; use App\Model\Page; use App\Model\Project; use App\Repository\BlameRepository; use App\Repository\PageRepository; use App\Tests\TestAdapter; +use GuzzleHttp\Exception\RequestException; /** * @covers \App\Model\Blame @@ -67,20 +69,76 @@ public function testPrepareData(): void { 'o_rev_id' => 3, 'editor' => 'Matthewrbowker', 'str' => 'foobar', + ], [ + 'o_rev_id' => 4, + 'editor' => 'Alien333', + 'str' => 'ooba', + ], [ + 'o_rev_id' => 4, + 'editor' => 'Alien333', + 'str' => 'bad', ], ], ], ] ], ] ); + $mockEdit = $this->createMock( 'App\Model\Edit' ); $this->blameRepo->expects( $this->exactly( 2 ) ) ->method( 'getEditFromRevId' ) - ->willReturn( $this->createMock( 'App\Model\Edit' ) ); + ->willReturn( $mockEdit ); $blame = new Blame( $this->blameRepo, $this->page, 'Foo bar' ); $blame->prepareData(); $matches = $blame->getMatches(); + // test it does cache + $blame->prepareData(); static::assertCount( 2, $matches ); + static::assertEquals( [ $mockEdit, $mockEdit ], $blame->getEdits() ); static::assertEquals( [ 3, 1 ], array_keys( $matches ) ); } + + /** + * Test fallback for Wikiwho errors + */ + public function testPrepareFallback(): void { + $this->blameRepo->expects( static::once() ) + ->method( 'getData' ) + ->willThrowException( $this->createMock( RequestException::class ) ); + $blame = new Blame( $this->blameRepo, $this->page, 'Foo bar' ); + $blame->prepareData(); + static::assertTrue( !isset( $blame->matches ) ); + } + + /** + * @dataProvider asOfProvider + * @param string|null $target + * @param Edit|null $edit + */ + public function testAsOf( ?string $target, ?Edit $edit ): void { + $blameRepo = $this->createMock( BlameRepository::class ); + $blameRepo->expects( static::exactly( $target ? 1 : 0 ) ) + ->method( 'getEditFromRevId' ) + ->willReturn( $edit ); + $blame = new Blame( $blameRepo, $this->page, 'Foo bar', $target ); + static::assertEquals( $edit, $blame->getAsOf() ); + // Get a second time to check caching + static::assertEquals( $edit, $blame->getAsOf() ); + } + + /** + * @return array + */ + public function asOfProvider(): array { + return [ + [ + "1234", + $this->createMock( Edit::class ), + ], + [ + null, + null, + ], + ]; + } } diff --git a/tests/Model/CategoryEditsTest.php b/tests/Model/CategoryEditsTest.php index 08772101b..c08808d85 100644 --- a/tests/Model/CategoryEditsTest.php +++ b/tests/Model/CategoryEditsTest.php @@ -82,22 +82,24 @@ public function testBasics(): void { * Methods around counting edits in category. */ public function testCategoryCounts(): void { + $counts = [ + 'Living_people' => [ 'editCount' => 150, 'pageCount' => 10 ], + 'Musicians_from_New_York_City' => [ 'editCount' => 50, 'pageCount' => 1 ], + ]; $this->ceRepo->expects( $this->once() ) ->method( 'countCategoryEdits' ) ->willReturn( 200 ); $this->ceRepo->expects( $this->once() ) ->method( 'getCategoryCounts' ) - ->willReturn( [ - 'Living_people' => 150, - 'Musicians_from_New_York_City' => 50, - ] ); + ->willReturn( $counts ); $this->ce->setRepository( $this->ceRepo ); static::assertEquals( 500, $this->ce->getEditCount() ); static::assertEquals( 200, $this->ce->getCategoryEditCount() ); + static::assertEquals( 11, $this->ce->getCategoryPageCount() ); static::assertEquals( 40.0, $this->ce->getCategoryPercentage() ); static::assertEquals( - [ 'Living_people' => 150, 'Musicians_from_New_York_City' => 50 ], + $counts, $this->ce->getCategoryCounts() ); diff --git a/tests/Model/EditCounterTest.php b/tests/Model/EditCounterTest.php index aaa04c778..049c49131 100644 --- a/tests/Model/EditCounterTest.php +++ b/tests/Model/EditCounterTest.php @@ -33,6 +33,7 @@ class EditCounterTest extends TestAdapter { protected ProjectRepository $projectRepo; protected User $user; protected UserRepository $userRepo; + protected AutomatedEditsHelper $autoEdits; /** * Set up shared mocks and class instances. @@ -51,6 +52,7 @@ public function setUp(): void { $this->userRepo = $this->createMock( UserRepository::class ); $this->user = new User( $this->userRepo, 'Testuser' ); + $this->autoEdits = $this->createMock( AutomatedEditsHelper::class ); $this->editCounter = new EditCounter( $this->editCounterRepo, @@ -58,7 +60,7 @@ public function setUp(): void { $this->createMock( UserRights::class ), $this->project, $this->user, - $this->createMock( AutomatedEditsHelper::class ) + $this->autoEdits, ); $this->editCounter->setRepository( $this->editCounterRepo ); } @@ -72,6 +74,7 @@ public function testLogCounts(): void { ->method( 'getLogCounts' ) ->willReturn( [ 'delete-delete' => 0, + 'delete-restore' => 2, 'move-move' => 1, 'block-block' => 2, 'block-reblock' => 3, @@ -129,6 +132,23 @@ public function testLogCounts(): void { static::assertEquals( 10, $this->editCounter->approvals() ); static::assertEquals( 3, $this->editCounter->accountsCreated() ); static::assertEquals( 9, $this->editCounter->reviews() ); + static::assertEquals( 2, $this->editCounter->countPagesRestored() ); + } + + /** + * Test counts of Commons uploads and local/Commons moves + */ + public function testFileCounts(): void { + $this->editCounterRepo->expects( static::exactly( 3 ) ) + ->method( 'getFileCounts' ) + ->willReturn( [ + 'files_moved' => 1, + 'files_moved_commons' => 2, + 'files_uploaded_commons' => 3, + ] ); + static::assertEquals( 3, $this->editCounter->countFilesUploadedCommons() ); + static::assertSame( 1, $this->editCounter->countFilesMoved() ); + static::assertEquals( 2, $this->editCounter->countFilesMovedCommons() ); } /** @@ -144,6 +164,8 @@ public function testLiveAndDeletedEdits(): void { 'minor' => 5, 'day' => 10, 'week' => 15, + 'edited-live' => 2, + 'edited-deleted' => 2, ] ); static::assertEquals( 100, $this->editCounter->countLiveRevisions() ); @@ -153,6 +175,20 @@ public function testLiveAndDeletedEdits(): void { static::assertEquals( 5, $this->editCounter->countMinorRevisions() ); static::assertEquals( 10, $this->editCounter->countRevisionsInLast( 'day' ) ); static::assertEquals( 15, $this->editCounter->countRevisionsInLast( 'week' ) ); + static::assertEquals( 27.5, $this->editCounter->averageRevisionsPerPage() ); + $this->editCounterRepo->expects( static::once() )->method( 'getFirstAndLatestActions' )->willReturn( [ + 'rev_first' => [ + 'id' => 123, + 'timestamp' => '20170510100000', + 'type' => null, + ], + 'rev_latest' => [ + 'id' => 321, + 'timestamp' => '20170515150000', + 'type' => null, + ], + ] ); + static::assertEquals( 22, $this->editCounter->averageRevisionsPerDay() ); } /** @@ -195,6 +231,24 @@ public function testFirstLastActions(): void { static::assertEquals( 5, $this->editCounter->getDays() ); } + /** + * Test the fallback if one of rev_first or rev_latest doesn't have a timestamp + */ + public function testMissingTimestamps(): void { + $this->editCounterRepo->expects( static::once() )->method( 'getFirstAndLatestActions' )->willReturn( [ + 'rev_first' => [ + 'id' => 123, + 'type' => null, + ], + 'rev_latest' => [ + 'id' => 321, + 'type' => null, + ], + ] ); + static::assertSame( 0, (int)$this->editCounter->getDays() ); + static::assertSame( 0, (int)$this->editCounter->averageRevisionsPerDay() ); + } + /** * Test that page counts are reported correctly. */ @@ -217,6 +271,20 @@ public function testPageCounts(): void { static::assertEquals( 8, $this->editCounter->countPagesCreated() ); } + /** + * Test fallbacks for when no pages were edited + */ + public function testNoPages(): void { + $this->editCounterRepo->expects( static::once() ) + ->method( 'getPairData' ) + ->willReturn( [ + 'edited-live' => 0, + 'edited-deleted' => 0, + ] ); + static::assertSame( 0, (int)$this->editCounter->countAllPagesEdited() ); + static::assertSame( 0, (int)$this->editCounter->averageRevisionsPerPage() ); + } + /** * Test that namespace totals are reported correctly. */ @@ -351,7 +419,7 @@ public function testYearCounts(): void { // Mock current time by passing it in (dummy parameter, so to speak). $yearCounts = $this->editCounter->yearCounts( new DateTime( '2017-04-30 23:59:59' ) ); - // Make sure zeros were filled in for months with no edits, and for each namespace. + // Make sure zeros were filled in for years with no edits, and for each namespace. static::assertArraySubset( [ 2015 => 0, @@ -377,20 +445,102 @@ public function testYearCounts(): void { // Labels for the years static::assertEquals( [ '2015', '2016', '2017' ], $yearCounts['yearLabels'] ); + + // Mock current time by passing it in (dummy parameter, so to speak). + $yearCountsWithNamespaces = $this->editCounter->yearCountsWithNamespaces( + new DateTime( '2017-04-30 23:59:59' ) + ); + + // Make sure zeros were filled in for years with no edits, and for each namespace. + static::assertArraySubset( + [ + 0 => 10, + 1 => 0, + ], + $yearCountsWithNamespaces[2016] + ); + + // Assert that only active years are reported + static::assertEquals( [ 2015, 2016, 2017 ], array_keys( $yearCountsWithNamespaces ) ); + + // Assert that only active namespaces are reported. + static::assertEquals( [ 0, 1 ], array_keys( $yearCountsWithNamespaces[2016] ) ); + + // Ensure we are caching that and will not make the query again + $this->editCounter->yearCounts( new DateTime( '2017-04-30 23:59:59' ) ); + } + + /** + * Test reordering and filling of timecard values + */ + public function testTimeCard(): void { + $this->editCounterRepo->expects( static::once() ) + ->method( "getTimeCard" ) + ->willReturn( [ + [ + // Sunday, 2 AM + 'day_of_week' => 1, + 'hour' => 2, + 'value' => 42, + ], + [ + // Wednesday, 3 PM + 'day_of_week' => 4, + 'hour' => 15, + 'value' => 33, + ], + ] ); + $results = $this->editCounter->timeCard(); + $hours = range( 0, 23 ); + $days = range( 1, 7 ); + // The hours are seven cycles from 0 to 23 + static::assertEquals( + array_merge( ...array_fill( 0, 7, $hours ) ), + array_map( static fn ( $row ) => $row['hour'], $results ) + ); + // The days are 24 of each of the seven, in order + static::assertEquals( + array_merge( ...array_map( static fn ( $day ) => array_fill( 0, 24, $day ), $days ) ), + array_map( static fn ( $row ) => $row['day_of_week'], $results ) + ); + // All values are positive + static::assertCount( + 0, + array_filter( $results, static fn ( $row ) => (int)$row['value'] < 0 ) + ); + + // Ensure we are caching and will not query again + $this->editCounter->timeCard(); } /** - * Ensure parsing of log_params properly works, based on known formats + * Test block logic * @dataProvider longestBlockProvider * @param array $blockLog * @param int $longestDuration + * @param int $blockCount */ - public function testLongestBlockSeconds( array $blockLog, int $longestDuration ): void { + public function testBlocks( array $blockLog, int $longestDuration, int $blockCount ): void { $this->editCounterRepo->expects( static::once() ) ->method( 'getBlocksReceived' ) ->with( $this->project, $this->user ) ->willReturn( $blockLog ); - static::assertEquals( $this->editCounter->getLongestBlockSeconds(), $longestDuration ); + static::assertEquals( $longestDuration, $this->editCounter->getLongestBlockSeconds() ); + $a = array_filter( + $this->editCounter->getBlocks( 'received' ), + // static fn ( $block ) => !in_array( $block['log_action'], [ 'block', 'reblock' ] ) + static function ( $block ) { + return !( $block['log_action'] === 'block' || $block['log_action'] === 'reblock' ); + } + ); + foreach ( $a as $x ) { + static::assertEquals( -3, $x['log_timestamp'] ); + } + static::assertCount( 0, $a ); + static::assertEquals( $blockCount, $this->editCounter->countBlocksReceived() ); + + // Ensure it is cached and we do not make that query a second time + $this->editCounter->getLongestBlockSeconds(); } /** @@ -415,6 +565,7 @@ public function longestBlockProvider(): array { ] ], // 31 days in seconds. 2678400, + 2, ], // Blocks that do overlap, without any unblocks. Combined 10 days. [ @@ -432,6 +583,7 @@ public function longestBlockProvider(): array { ] ], // 10 days in seconds. 864000, + 2, ], // 30 day block that was later unblocked at only 10 days, followed by a shorter block. [ @@ -454,6 +606,7 @@ public function longestBlockProvider(): array { ] ], // 10 days in seconds. 864000, + 2, ], // Blocks ending with a still active indefinite block. Older block uses legacy format. [ @@ -470,16 +623,18 @@ public function longestBlockProvider(): array { ] ], // Indefinite -1, + 2, ], // Block that's active, with an explicit expiry set. [ [ [ 'log_timestamp' => '20170927203624', - 'log_params' => 'a:2:{s:11:"5::duration";s:29:"Sat, 06 Oct 2026 12:36:00 GMT"' . + 'log_params' => 'a:2:{s:11:"5::duration";s:29:"Sat, 06 Oct 9999 12:36:00 GMT"' . ';s:8:"6::flags";s:11:"noautoblock";}', 'log_action' => 'block', ] ], - 285091176, + 251888543976, + 1, ], // Two indefinite blocks. [ @@ -496,6 +651,30 @@ public function longestBlockProvider(): array { 'log_action' => 'reblock', ] ], -1, + 2, + ], + // No blocks; 0 seconds + [ + [], + 0, + 0, + ], + // Finite block that was reblocked to infinite + [ + [ [ + 'log_timestamp' => '20160513200200', + 'log_params' => 'a:2:{s:11:"5::duration";s:10:"24 hours"' . + ';s:8:"6::flags";s:19:"nocreate,nousertalk";}', + 'log_action' => 'block', + ], + [ + 'log_timestamp' => '20160717021328', + 'log_params' => 'a:2:{s:11:"5::duration";s:8:"infinite"' . + ';s:8:"6::flags";s:31:"nocreate,noautoblock,nousertalk";}', + 'log_action' => 'reblock', + ] ], + -1, + 2, ], ]; } @@ -558,4 +737,65 @@ public function blockLogProvider(): array { ], ]; } + + /** + * Test counting of edit data + * @dataProvider editDataProvider + * @param array $data + * @param array $qualityChanges + * @param int|float $averageSize + * @param int $autoEdits + */ + public function testEditData( + array $data, + array $qualityChanges, + $averageSize, + int $autoEdits, + ): void { + $this->autoEdits->expects( isset( $data['tag_lists'] ) ? static::once() : static::never() ) + ->method( "getTags" ) + ->willReturn( [ + 'AWB', + ] ); + $this->editCounterRepo->expects( static::once() ) + ->method( "getEditData" ) + ->willReturn( $data ); + static::assertEquals( $qualityChanges, $this->editCounter->countQualityChanges() ); + static::assertEquals( $averageSize, $this->editCounter->averageEditSize() ); + static::assertEquals( $autoEdits, $this->editCounter->countAutoEdits() ); + } + + /** + * Data for self::testEditData + * @return array + */ + public function editDataProvider(): array { + return [ + [ + [ + 'tag_lists' => [ + [ 'randomtag', 'proofreadpage-quality1' ], + [ 'proofreadpage-quality2', 'proofreadpage-quality0' ], + [ 'AWB' ], + [ 'proofreadpage-quality2', 'AWB' ], + [ 'proofreadpage-quality3', 'a' ], + [ 'proofreadpagequality0', 'proofreadpage-quality3' ], + [ 'prp-quality0', 'proofreadpage-quality3' ], + ], + 'average_size' => 3.1415926, + ], + [ 0 => 0, 1 => 1, 2 => 2, 3 => 3, 4 => 0, 'total' => 6 ], + 3.142, + 2, + ], + [ + [ + // Empty + ], + [ 0 => 0, 1 => 0, 2 => 0, 3 => 0, 4 => 0, 'total' => 0 ], + 0, + 0, + ], + ]; + } } diff --git a/tests/Model/EditTest.php b/tests/Model/EditTest.php index ee99c2bd7..c887a95fb 100644 --- a/tests/Model/EditTest.php +++ b/tests/Model/EditTest.php @@ -58,6 +58,9 @@ public function setUp(): void { 'general' => [ 'articlePath' => '/wiki/$1', ], + 'namespaces' => [ + 1 => 'Talk', + ], ] ); $this->project->setRepository( $this->projectRepo ); $this->pageRepo = $this->createMock( PageRepository::class ); @@ -95,6 +98,9 @@ public function testBasic(): void { static::assertEquals( 'abcdef', $edit->getSha() ); static::assertSame( '1', $edit->getCacheKey() ); static::assertFalse( $edit->isReverted() ); + // Test fallback for invalid timestamp + $edit = $this->getEditFactory( [ 'timestamp' => [] ] ); + static::assertEquals( new DateTime( '1970-01-01T00:00:00Z' ), $edit->getTimestamp() ); } /** @@ -136,6 +142,15 @@ public function testWikifiedComment(): void { 'https://example.org', $edit->getWikifiedSummary() ); + + $edit = $this->getEditFactory( [ + 'comment' => '/* Section */', + ] ); + static::assertEquals( + "" . + "Section: ", + $edit->getWikifiedSummary() + ); } /** @@ -188,13 +203,14 @@ public function testIsAutomated(): void { * Test some basic getters. */ public function testGetters(): void { - $edit = $this->getEditFactory(); + $edit = $this->getEditFactory( [ 'tags' => json_encode( [ 'A', 'B' ] ) ] ); static::assertSame( '2017', $edit->getYear() ); static::assertSame( '01', $edit->getMonth() ); static::assertEquals( 12, $edit->getLength() ); static::assertEquals( 2, $edit->getSize() ); static::assertEquals( 2, $edit->getLengthChange() ); static::assertEquals( 'Testuser', $edit->getUser()->getUsername() ); + static::assertContains( 'A', $edit->getTags() ); } /** @@ -235,13 +251,19 @@ public function testIsAnon(): void { } public function testGetForJson(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->method( 'getPageInfo' ) + ->willReturn( [ + 'ns' => 1, + ] ); + $this->page = new Page( $pageRepo, $this->project, 'Talk:Test_page' ); $edit = $this->getEditFactory(); static::assertEquals( [ 'project' => 'en.wikipedia.org', 'username' => 'Testuser', 'page_title' => 'Test page', - 'namespace' => $this->page->getNamespace(), + 'namespace' => 1, 'rev_id' => 1, 'timestamp' => '2017-01-01T10:00:00Z', 'minor' => false, diff --git a/tests/Model/GlobalContribsTest.php b/tests/Model/GlobalContribsTest.php index abd60d000..9d85c1e65 100644 --- a/tests/Model/GlobalContribsTest.php +++ b/tests/Model/GlobalContribsTest.php @@ -73,8 +73,39 @@ public function testGlobalEditCounts(): void { /** * Test global edits. + * @dataProvider globalEditsProvider + * @param array $contribs + * @param array $projects + * @param int $count + * @param int $projectCount */ - public function testGlobalEdits(): void { + public function testGlobalEdits( + array $contribs, + array $projects, + int $count, + int $projectCount + ): void { + $globalContribsRepo = $this->createMock( GlobalContribsRepository::class ); + $globalContribsRepo->expects( static::exactly( 2 + ( count( $projects ) ? 0 : 1 ) ) ) + ->method( 'getProjectsWithEdits' ) + ->willReturn( $projects ); + $globalContribsRepo->expects( static::any() ) + ->method( 'getRevisions' ) + ->willReturn( $contribs ); + $this->globalContribs->setRepository( $globalContribsRepo ); + + $edits = $this->globalContribs->globalEdits(); + + static::assertCount( $count, $edits ); + if ( $count > 0 ) { + static::assertEquals( 'My user page', $edits['1514764800-1']->getComment() ); + } + static::assertEquals( $projectCount, $this->globalContribs->numProjectsWithEdits() ); + + $this->globalContribs->globalEdits(); + } + + public function globalEditsProvider(): array { /** @var ProjectRepository|MockObject $wiki1Repo */ $wiki1Repo = $this->createMock( ProjectRepository::class ); $wiki1Repo->expects( static::once() ) @@ -88,8 +119,7 @@ public function testGlobalEdits(): void { ] ); $wiki1 = new Project( 'wiki1' ); $wiki1->setRepository( $wiki1Repo ); - - $contribs = [ [ + $edit = [ [ 'dbName' => 'wiki1', 'id' => 1, 'timestamp' => '20180101000000', @@ -104,19 +134,26 @@ public function testGlobalEdits(): void { 'namespace' => '2', 'comment' => 'My user page', ] ]; - - $this->globalContribsRepo->expects( static::once() ) - ->method( 'getProjectsWithEdits' ) - ->willReturn( [ - 'wiki1' => $wiki1, - ] ); - $this->globalContribsRepo->expects( static::once() ) - ->method( 'getRevisions' ) - ->willReturn( $contribs ); - - $edits = $this->globalContribs->globalEdits(); - - static::assertCount( 1, $edits ); - static::assertEquals( 'My user page', $edits['1514764800-1']->getComment() ); + return [ + [ + // Dataset #0: normal case. 1 edit, 1 project + $edit, + [ 'wiki1' => $wiki1 ], + 1, + 1, + ], [ + // Dataset #1: project for edit is null + $edit, + [ 'wiki1' => null ], + 0, + 1, + ], [ + // Dataset #2: no projects and no edit + [], + [], + 0, + 0, + ], + ]; } } diff --git a/tests/Model/PageAssessmentsTest.php b/tests/Model/PageAssessmentsTest.php index e1f9af907..8f15ecac6 100644 --- a/tests/Model/PageAssessmentsTest.php +++ b/tests/Model/PageAssessmentsTest.php @@ -63,7 +63,13 @@ public function testBasics(): void { * Badges */ public function testBadges(): void { - $pa = new PageAssessments( $this->paRepo, $this->project ); + $config = $this->paRepo->getConfig( $this->project ); + $config['class']['Unknown'] = null; + $paRepo = $this->createMock( PageAssessmentsRepository::class ); + $paRepo->expects( $this->once() ) + ->method( 'getConfig' ) + ->willReturn( $config ); + $pa = new PageAssessments( $paRepo, $this->project ); static::assertEquals( 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg', @@ -74,6 +80,11 @@ public function testBadges(): void { 'Featured_article_star.svg', $pa->getBadgeURL( 'FA', true ) ); + + static::assertSame( + '', + $pa->getBadgeURL( 'Bonjour', true ) + ); } /** @@ -87,7 +98,7 @@ public function testGetAssessments(): void { ] ); $page = new Page( $pageRepo, $this->project, 'Test_page' ); - $this->paRepo->expects( $this->once() ) + $this->paRepo->expects( $this->exactly( 2 ) ) ->method( 'getAssessments' ) ->with( $page ) ->willReturn( [ @@ -106,6 +117,7 @@ public function testGetAssessments(): void { $pa = new PageAssessments( $this->paRepo, $this->project ); $assessments = $pa->getAssessments( $page ); + $assessment = $pa->getAssessment( $page ); // Picks the first assessment. static::assertEquals( [ @@ -114,7 +126,67 @@ public function testGetAssessments(): void { 'category' => 'Category:Start-Class articles', 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/a/a4/Symbol_start_class.svg', ], $assessments['assessment'] ); + static::assertEquals( [ + 'color' => '#FFAA66', + 'category' => 'Category:Start-Class articles', + 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/a/a4/Symbol_start_class.svg', + 'value' => 'Start', + ], $assessment ); static::assertCount( 2, $assessments['wikiprojects'] ); } + + public function testWrongNsAssessments(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->method( 'getPageInfo' )->willReturn( [ + 'title' => 'Talk:Test Page', + 'ns' => 1, + ] ); + $page = new Page( $pageRepo, $this->project, 'Talk:Test_page' ); + $pa = new PageAssessments( $this->paRepo, $this->project ); + static::assertFalse( $pa->getAssessment( $page ) ); + static::assertNull( $pa->getAssessments( $page ) ); + } + + public function testUnknownAssessment(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->method( 'getPageInfo' )->willReturn( [ + 'title' => 'Test Page', + 'ns' => 6, + ] ); + $page = new Page( $pageRepo, $this->project, 'Test_page' ); + $this->paRepo->expects( static::exactly( 2 ) ) + ->method( 'getAssessments' ) + ->willReturn( [] ); + $pa = new PageAssessments( $this->paRepo, $this->project ); + static::assertEquals( [ + 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/e/e0/Symbol_question.svg', + 'color' => '', + 'category' => 'Category:Unassessed articles', + 'value' => '???', + ], $pa->getAssessment( $page ) ); + static::assertEquals( [], $pa->getAssessments( $page ) ); + } + + public function testImportanceFromUnknownAssessment(): void { + $pa = new PageAssessments( $this->paRepo, $this->project ); + static::assertEquals( [ + 'color' => '', + 'category' => 'Category:Unknown-importance articles', + 'weight' => 0, + 'value' => '???', + ], $pa->getImportanceFromAssessment( [ 'importance' => '' ] ) ); + } + + public function testImportanceWithMissingConfig(): void { + // To make it happy about being used once: + $this->paRepo->getConfig( $this->project ); + // Also ensures we don't use it by mistake in the next tests. + $paRepo = $this->createMock( PageAssessmentsRepository::class ); + $paRepo->expects( static::once() ) + ->method( 'getConfig' ) + ->willReturn( [] ); + $pa = new PageAssessments( $paRepo, $this->project ); + static::assertNull( $pa->getImportanceFromAssessment( [ 'importance' => '' ] ) ); + } } diff --git a/tests/Model/PageInfoTest.php b/tests/Model/PageInfoTest.php index 29ee9b783..14d4dd8e7 100644 --- a/tests/Model/PageInfoTest.php +++ b/tests/Model/PageInfoTest.php @@ -16,6 +16,7 @@ use App\Repository\PageRepository; use App\Repository\UserRepository; use App\Tests\TestAdapter; +use DateTime; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use ReflectionClass; @@ -47,7 +48,7 @@ public function setUp(): void { $i18nHelper = static::getContainer()->get( 'app.i18n_helper' ); $this->project = $this->getMockEnwikiProject(); $this->pageRepo = $this->createMock( PageRepository::class ); - $this->page = new Page( $this->pageRepo, $this->project, 'Test page' ); + $this->page = $this->createMock( Page::class ); $this->editRepo = $this->createMock( EditRepository::class ); $this->editRepo->method( 'getAutoEditsHelper' ) ->willReturn( $autoEditsHelper ); @@ -62,8 +63,7 @@ public function setUp(): void { $this->page ); - // Don't care that private methods "shouldn't" be tested... - // In PageInfo they are all super test-worthy and otherwise fragile. + // Used to set a few private properties without having to recreate everything $this->reflectionClass = new ReflectionClass( $this->pageInfo ); } @@ -71,9 +71,11 @@ public function setUp(): void { * Number of revisions */ public function testNumRevisions(): void { - $this->pageRepo->expects( $this->once() ) + $this->setupData(); + $this->page->expects( static::once() ) ->method( 'getNumRevisions' ) ->willReturn( 10 ); + $this->pageInfo->prepareData(); static::assertEquals( 10, $this->pageInfo->getNumRevisions() ); // Should be cached (will error out if repo's getNumRevisions is called again). static::assertEquals( 10, $this->pageInfo->getNumRevisions() ); @@ -86,7 +88,7 @@ public function testNumRevisions(): void { * @param int $assertion */ public function testRevisionsProcessed( int $numRevisions, int $assertion ): void { - $this->pageRepo->method( 'getNumRevisions' )->willReturn( $numRevisions ); + $this->page->method( 'getNumRevisions' )->willReturn( $numRevisions ); static::assertEquals( $this->pageInfo->getNumRevisionsProcessed(), $assertion @@ -108,17 +110,31 @@ public function revisionsProcessedProvider(): array { * Whether there are too many revisions to process. */ public function testTooManyRevisions(): void { - $this->pageRepo->expects( $this->once() ) + $this->page->expects( static::once() ) ->method( 'getNumRevisions' ) ->willReturn( 1000000 ); static::assertTrue( $this->pageInfo->tooManyRevisions() ); } /** - * Getting the number of edits made to the page by current or former bots. + * Various bot-related methods */ - public function testBotRevisionCount(): void { - $bots = [ + public function testBots(): void { + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getBotData' ) + ->willReturn( [ + [ + 'username' => 'Foo', + 'count' => 3, + 'current' => '1', + ], + [ + 'username' => 'Bar', + 'count' => 12, + 'current' => '0', + ], + ] ); + static::assertEquals( [ 'Foo' => [ 'count' => 3, 'current' => true, @@ -127,16 +143,75 @@ public function testBotRevisionCount(): void { 'count' => 12, 'current' => false, ], - ]; + ], $this->pageInfo->getBots() ); + static::assertEquals( 2, $this->pageInfo->getNumBots() ); + static::assertEquals( 15, $this->pageInfo->getBotRevisionCount() ); + // second time for caching + static::assertEquals( 15, $this->pageInfo->getBotRevisionCount() ); + } - static::assertEquals( - 15, - $this->pageInfo->getBotRevisionCount( $bots ) - ); + public function testTopEditorsByEditCount(): void { + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getTopEditorsByEditCount' ) + ->willReturn( [ + [ + 'username' => 'Foo', + 'count' => 22, + 'minor' => 6, + 'first_revid' => 100, + 'first_timestamp' => '10000101000100', + 'latest_revid' => 300, + 'latest_timestamp' => '10000101000300', + ], + [ + 'username' => 'Bar', + 'count' => 20, + 'minor' => 4, + 'first_revid' => 200, + 'first_timestamp' => '10000101000200', + 'latest_revid' => 400, + 'latest_timestamp' => '10000101000400', + ], + ] ); + static::assertEquals( [ + [ + 'rank' => 1, + 'username' => 'Foo', + 'count' => 22, + 'minor' => 6, + 'first_edit' => [ + 'id' => 100, + 'timestamp' => '1000-01-01T00:01:00Z', + ], + 'latest_edit' => [ + 'id' => 300, + 'timestamp' => '1000-01-01T00:03:00Z', + ], + ], + [ + 'rank' => 2, + 'username' => 'Bar', + 'count' => 20, + 'minor' => 4, + 'first_edit' => [ + 'id' => 200, + 'timestamp' => '1000-01-01T00:02:00Z', + ], + 'latest_edit' => [ + 'id' => 400, + 'timestamp' => '1000-01-01T00:04:00Z', + ], + ], + ], $this->pageInfo->getTopEditorsByEditCount() ); + // Test caching + $this->pageInfo->getTopEditorsByEditCount(); } public function testLinksAndRedirects(): void { - $this->pageRepo->expects( $this->once() ) + $this->setupData(); + // Ensure we don't call the revisions (the second time will complain). + $this->pageInfo->prepareData(); + $this->page->expects( static::once() ) ->method( 'countLinksAndRedirects' ) ->willReturn( [ 'links_ext_count' => 5, @@ -144,29 +219,47 @@ public function testLinksAndRedirects(): void { 'links_in_count' => 10, 'redirects_count' => 0, ] ); - $this->page->setRepository( $this->pageRepo ); static::assertEquals( 5, $this->pageInfo->linksExtCount() ); static::assertEquals( 3, $this->pageInfo->linksOutCount() ); static::assertEquals( 10, $this->pageInfo->linksInCount() ); static::assertSame( 0, $this->pageInfo->redirectsCount() ); } + public function testBugs(): void { + $this->page->expects( static::once() ) + ->method( 'getErrors' ) + ->willReturn( [] ); + static::assertSame( [], $this->pageInfo->getBugs() ); + // Ensure caching + static::assertSame( [], $this->pageInfo->getBugs() ); + static::assertSame( 0, $this->pageInfo->numBugs() ); + } + /** * Test some of the more important getters. */ public function testGetters(): void { - $edits = $this->setupData(); + $this->setupData(); + $this->pageInfo->prepareData(); + static::assertEquals( + 32, + $this->pageInfo->getFirstEdit()->getId() + ); + static::assertEquals( + 60, + $this->pageInfo->getLastEdit()->getId() + ); static::assertEquals( 3, $this->pageInfo->getNumEditors() ); static::assertEquals( 2, $this->pageInfo->getAnonCount() ); static::assertEquals( 40, $this->pageInfo->anonPercentage() ); static::assertEquals( 3, $this->pageInfo->getMinorCount() ); static::assertEquals( 60, $this->pageInfo->minorPercentage() ); static::assertSame( 1, $this->pageInfo->getBotRevisionCount() ); - static::assertEquals( 93, $this->pageInfo->getTotalDays() ); - static::assertEquals( 18, (int)$this->pageInfo->averageDaysPerEdit() ); + static::assertEquals( 63, $this->pageInfo->getTotalDays() ); + static::assertEquals( 12, (int)$this->pageInfo->averageDaysPerEdit() ); static::assertSame( 0, (int)$this->pageInfo->editsPerDay() ); - static::assertEquals( 1.6, $this->pageInfo->editsPerMonth() ); + static::assertEquals( 2.4, $this->pageInfo->editsPerMonth() ); static::assertEquals( 5, $this->pageInfo->editsPerYear() ); static::assertEquals( 1.7, $this->pageInfo->editsPerEditor() ); static::assertEquals( 2, $this->pageInfo->getAutomatedCount() ); @@ -175,20 +268,11 @@ public function testGetters(): void { static::assertEquals( 80, $this->pageInfo->topTenPercentage() ); static::assertEquals( 4, $this->pageInfo->getTopTenCount() ); - static::assertEquals( - $edits[0]->getId(), - $this->pageInfo->getFirstEdit()->getId() - ); - static::assertEquals( - $edits[4]->getId(), - $this->pageInfo->getLastEdit()->getId() - ); - static::assertSame( 1, $this->pageInfo->getMaxAddition()->getId() ); static::assertEquals( 32, $this->pageInfo->getMaxDeletion()->getId() ); static::assertEquals( - [ 'Mick Jagger', '192.168.0.1', '192.168.0.2' ], + [ 'Mick Jagger', '192.168.0.2', '192.168.0.1' ], array_keys( $this->pageInfo->getEditors() ) ); static::assertEquals( @@ -232,6 +316,106 @@ public function testGetters(): void { ); static::assertSame( 1, $this->pageInfo->numDeletedRevisions() ); + static::assertEquals( 2, $this->pageInfo->getMobileCount() ); + static::assertEquals( 2, $this->pageInfo->getVisualCount() ); + } + + /** + * Test max removal reverting. + * Could theoretically be done above but would require changing many values. + */ + public function testMaxRemovalRevert(): void { + $this->page->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( [ + [ + 'id' => 1, + 'timestamp' => '20010203040506', + 'minor' => '0', + 'length' => '30', + 'length_change' => '-1', + 'username' => null, + 'comment' => 'Foo bar', + 'rev_sha1' => 'aaaaaa', + ], + [ + 'id' => 2, + 'timestamp' => '20010203040506', + 'minor' => '0', + 'length' => '30', + 'length_change' => '-2', + 'username' => null, + 'comment' => 'Foo bar', + 'rev_sha1' => 'bbbbbb', + ], + [ + 'id' => 3, + 'timestamp' => '20010203040508', + 'minor' => '0', + 'length' => '30', + 'length_change' => '2', + 'username' => null, + 'comment' => 'Foo bar', + 'rev_sha1' => 'aaaaaa', + ], + ] ); + $this->pageInfoRepo->expects( static::exactly( 3 ) ) + ->method( 'getEdit' ) + ->willReturnCallback( fn ( $page, $rev ) => new Edit( $this->editRepo, $this->userRepo, $page, $rev ) ); + $this->pageInfo->prepareData(); + static::assertSame( 1, $this->pageInfo->getMaxDeletion()->getId() ); + } + + /** + * Make sure we don't divide by 0 + */ + public function testEmptyFallbacks(): void { + $this->page->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( [ + [ + 'id' => 1, + 'timestamp' => '20010203040506', + 'minor' => '0', + 'length' => '30', + 'length_change' => '30', + 'username' => null, + 'comment' => 'Foo bar', + 'rev_sha1' => 'aaaaaa', + 'tags' => '["mobile edit"]', + ], + ] ); + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getEdit' ) + ->willReturnCallback( fn ( $page, $rev ) => new Edit( $this->editRepo, $this->userRepo, $page, $rev ) ); + $this->pageInfo->prepareData(); + static::assertSame( 0, (int)$this->pageInfo->editsPerDay() ); + static::assertSame( 0, (int)$this->pageInfo->editsPerMonth() ); + static::assertSame( 0, (int)$this->pageInfo->editsPerYear() ); + static::assertSame( 0, (int)$this->pageInfo->editsPerEditor() ); + } + + public function testCountHistory(): void { + $this->page->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( [ + [ + 'id' => 1, + 'timestamp' => ( new DateTime( 'now' ) )->format( 'YmdHis' ), + 'minor' => '0', + 'length' => '30', + 'length_change' => '30', + 'username' => null, + 'comment' => 'Foo bar', + 'rev_sha1' => 'aaaaaa', + 'tags' => '["mobile edit"]', + ], + ] ); + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getEdit' ) + ->willReturnCallback( fn ( $page, $rev ) => new Edit( $this->editRepo, $this->userRepo, $page, $rev ) ); + $this->pageInfo->prepareData(); + static::assertEquals( [ 1, 1, 1, 1 ], array_values( $this->pageInfo->getCountHistory() ) ); } /** @@ -239,6 +423,7 @@ public function testGetters(): void { */ public function testMonthYearCounts(): void { $this->setupData(); + $this->pageInfo->prepareData(); $yearMonthCounts = $this->pageInfo->getYearMonthCounts(); @@ -253,21 +438,15 @@ public function testMonthYearCounts(): void { ], $yearMonthCounts[2016] ); static::assertEquals( - [ '07', '08', '09', '10', '11', '12' ], + [ '08', '09', '10', '11', '12' ], array_keys( $yearMonthCounts[2016]['months'] ) ); static::assertEquals( - [ '2016-07', '2016-08', '2016-09', '2016-10', '2016-11', '2016-12' ], + [ '2016-08', '2016-09', '2016-10', '2016-11', '2016-12' ], $this->pageInfo->getMonthLabels() ); // Just test a few, not every month. - static::assertArraySubset( [ - 'all' => 1, - 'minor' => 0, - 'anon' => 0, - 'automated' => 0, - ], $yearMonthCounts[2016]['months']['07'] ); static::assertArraySubset( [ 'all' => 3, 'minor' => 2, @@ -282,7 +461,7 @@ public function testMonthYearCounts(): void { public function testLogEvents(): void { $this->setupData(); - $this->pageInfoRepo->expects( $this->once() ) + $this->pageInfoRepo->expects( static::once() ) ->method( 'getLogEvents' ) ->willReturn( [ [ @@ -293,47 +472,57 @@ public function testLogEvents(): void { 'log_type' => 'delete', 'timestamp' => '20160905000000', ], + [ + 'log_type' => 'delete', + 'timestamp' => '20160905000001', + ], [ 'log_type' => 'move', 'timestamp' => '20161005000000', ], ] ); - $method = $this->reflectionClass->getMethod( 'setLogsEvents' ); - $method->setAccessible( true ); - $method->invoke( $this->pageInfo ); - + $this->pageInfo->prepareData(); $yearMonthCounts = $this->pageInfo->getYearMonthCounts(); // Just test a few, not every month. static::assertEquals( [ 'protections' => 1, - 'deletions' => 1, + 'deletions' => 2, 'moves' => 1, ], $yearMonthCounts[2016]['events'] ); } /** - * Use ReflectionClass to set up some data and populate the class properties for testing. - * - * We don't care that private methods "shouldn't" be tested... - * In PageInfo the update methods are all super test-worthy and otherwise fragile. - * - * @return Edit[] Array of Edit objects that represent the revision history. + * Make sure that setLogEvents does nothing when yearMonthCounts is not set */ - private function setupData(): array { - $edits = [ - new Edit( $this->editRepo, $this->userRepo, $this->page, [ + public function testLogEventsFallback(): void { + // Intentionally don't setup, so addYearMonthCountEntry never gets called + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getLogEvents' ) + ->willReturn( [ [ 'timestamp' => 'yesterday' ] ] ); + // Will call setLogEvents under the hood + $this->pageInfo->prepareData(); + static::assertSame( [], $this->pageInfo->getYearMonthCounts() ); + } + + /** + * Set repository returns + */ + private function setupData(): void { + $revisions = [ + [ 'id' => 1, - 'timestamp' => '20160701101205', + 'timestamp' => '20160801000001', 'minor' => '0', 'length' => '30', 'length_change' => '30', 'username' => 'Mick Jagger', 'comment' => 'Foo bar', 'rev_sha1' => 'aaaaaa', - ] ), - new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'tags' => '["mobile edit"]', + ], + [ 'id' => 32, 'timestamp' => '20160801000000', 'minor' => '1', @@ -342,28 +531,31 @@ private function setupData(): array { 'username' => 'Mick Jagger', 'comment' => 'Blah', 'rev_sha1' => 'bbbbbb', - ] ), - new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'tags' => '[]', + ], + [ 'id' => 40, 'timestamp' => '20161003000000', 'minor' => '0', 'length' => '15', - 'length_change' => '-10', + 'length_change' => '1000', 'username' => '192.168.0.1', 'comment' => 'Weeee using [[WP:AWB|AWB]]', 'rev_sha1' => 'cccccc', - ] ), - new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'tags' => '["mobile edit","visualeditor"]', + ], + [ 'id' => 50, 'timestamp' => '20161003010000', 'minor' => '1', 'length' => '25', - 'length_change' => '10', + 'length_change' => '-1000', 'username' => '192.168.0.2', 'comment' => 'I undo your edit cuz it bad', 'rev_sha1' => 'bbbbbb', - ] ), - new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'tags' => '["visualeditor"]', + ], + [ 'id' => 60, 'timestamp' => '20161003020000', 'minor' => '1', @@ -373,47 +565,21 @@ private function setupData(): array { 'comment' => 'Weeee using [[WP:AWB|AWB]]', 'rev_sha1' => 'ddddd', 'rev_deleted' => Edit::DELETED_USER, - ] ), - ]; - - $prevEdits = [ - 'prev' => null, - 'prevSha' => null, - 'maxAddition' => null, - 'maxDeletion' => null, + 'tags' => '[]', + ], ]; - - $prop = $this->reflectionClass->getProperty( 'firstEdit' ); - $prop->setAccessible( true ); - $prop->setValue( $this->pageInfo, $edits[0] ); - - $prop = $this->reflectionClass->getProperty( 'numRevisionsProcessed' ); - $prop->setAccessible( true ); - $prop->setValue( $this->pageInfo, 5 ); - - $prop = $this->reflectionClass->getProperty( 'bots' ); - $prop->setAccessible( true ); - $prop->setValue( $this->pageInfo, [ - 'XtoolsBot' => [ 'count' => 1 ], - ] ); - - $prop = $this->reflectionClass->getProperty( 'numDeletedRevisions' ); - $prop->setAccessible( true ); - $prop->setValue( $this->pageInfo, 1 ); - - $method = $this->reflectionClass->getMethod( 'updateCounts' ); - $method->setAccessible( true ); - $prevEdits = $method->invoke( $this->pageInfo, $edits[0], $prevEdits ); - $prevEdits = $method->invoke( $this->pageInfo, $edits[1], $prevEdits ); - $prevEdits = $method->invoke( $this->pageInfo, $edits[2], $prevEdits ); - $prevEdits = $method->invoke( $this->pageInfo, $edits[3], $prevEdits ); - $method->invoke( $this->pageInfo, $edits[4], $prevEdits ); - - $method = $this->reflectionClass->getMethod( 'doPostPrecessing' ); - $method->setAccessible( true ); - $method->invoke( $this->pageInfo ); - - return $edits; + $this->page->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( $revisions ); + $this->pageInfoRepo->expects( static::exactly( count( $revisions ) ) ) + ->method( 'getEdit' ) + ->willReturnCallback( fn ( $page, $rev ) => new Edit( $this->editRepo, $this->userRepo, $page, $rev ) ); + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getBotData' ) + ->willReturn( [ [ 'count' => 1, 'username' => 'XtoolsBot', 'current' => 1 ] ] ); + $this->pageInfoRepo->expects( static::any() ) + ->method( 'getMaxPageRevisions' ) + ->willReturn( 10 ); } /** @@ -425,10 +591,9 @@ public function testProseStats(): void { $ret = $client->request( 'GET', 'https://en.wikipedia.org/api/rest_v1/page/html/Hanksy/747629772' ) ->getBody() ->getContents(); - $this->pageRepo->expects( $this->once() ) + $this->page->expects( static::once() ) ->method( 'getHTMLContent' ) ->willReturn( $ret ); - $this->page->setRepository( $this->pageRepo ); static::assertEquals( [ 'bytes' => 1539, @@ -438,6 +603,44 @@ public function testProseStats(): void { 'unique_references' => 12, 'sections' => 2, ], $this->pageInfo->getProseStats() ); + // Test caching + $this->pageInfo->getProseStats(); + } + + /** + * Ensure we react appropriately when getHTMLContent fails + */ + public function testProseStatFallback(): void { + $this->page->expects( static::once() ) + ->method( 'getHTMLContent' ) + ->willThrowException( $this->createMock( BadGateWayException::class ) ); + static::assertNull( $this->pageInfo->getProseStats() ); + } + + /** + * Ensure we don't divide by 0 when the page had no added text + */ + public function testZeroAddedBytes(): void { + $this->page->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( [ + [ + 'id' => 1, + 'timestamp' => '20160801000001', + 'minor' => '0', + 'length' => '0', + 'length_change' => '0', + 'username' => 'Mick Jagger', + 'comment' => 'Foo bar', + 'rev_sha1' => 'aaaaaa', + 'tags' => '["mobile edit"]', + ], + ] ); + $this->pageInfoRepo->expects( static::once() ) + ->method( 'getEdit' ) + ->willReturnCallback( fn ( $page, $rev ) => new Edit( $this->editRepo, $this->userRepo, $page, $rev ) ); + $this->pageInfo->prepareData(); + static::assertSame( 0, $this->pageInfo->topTenEditorsByAdded()[0]['percentage'] ); } /** @@ -445,14 +648,16 @@ public function testProseStats(): void { */ public function testWithDates(): void { $this->setupData(); + $this->pageInfo->prepareData(); - $prop = $this->reflectionClass->getProperty( 'start' ); - $prop->setAccessible( true ); - $prop->setValue( $this->pageInfo, strtotime( '2016-06-30' ) ); + $start = $this->reflectionClass->getProperty( 'start' ); + $start->setValue( $this->pageInfo, strtotime( '2016-06-30' ) ); - $prop = $this->reflectionClass->getProperty( 'end' ); - $prop->setAccessible( true ); - $prop->setValue( $this->pageInfo, strtotime( '2016-10-14' ) ); + $end = $this->reflectionClass->getProperty( 'end' ); + $end->setValue( $this->pageInfo, strtotime( '2016-10-14' ) ); + + $meth = $this->reflectionClass->getMethod( 'getLastDay' ); + $lastDayOfMonth = $meth->invoke( $this->pageInfo ); static::assertTrue( $this->pageInfo->hasDateRange() ); static::assertEquals( '2016-06-30', $this->pageInfo->getStartDate() ); @@ -461,21 +666,25 @@ public function testWithDates(): void { 'start' => '2016-06-30', 'end' => '2016-10-14', ], $this->pageInfo->getDateParams() ); + static::assertEquals( strtotime( '2016-10-31' ), $lastDayOfMonth ); // Uses length of last edit because there is a date range. static::assertEquals( 20, $this->pageInfo->getLength() ); // Pageviews with a date range. - $this->pageRepo->expects( $this->once() ) + $this->page->expects( static::once() ) ->method( 'getPageviews' ) - ->with( $this->page, '2016-06-30', '2016-10-14' ) - ->willReturn( [ - 'items' => [ - [ 'views' => 1000 ], - [ 'views' => 500 ], - ], - ] ); + ->willReturn( 1500 ); static::assertEquals( 1500, $this->pageInfo->getPageviews()['count'] ); + + // no dates + $start->setValue( $this->pageInfo, false ); + $end->setValue( $this->pageInfo, false ); + $this->page->expects( static::once() ) + ->method( 'getLength' ) + ->willReturn( 42 ); + static::assertEquals( [], $this->pageInfo->getDateParams() ); + static::assertEquals( 42, $this->pageInfo->getLength() ); } /** @@ -498,14 +707,12 @@ public function testTransclusionData(): void { } public function testPageviews(): void { - $this->pageRepo->expects( $this->once() ) + $this->page->expects( static::exactly( $this->pageInfo->hasDateRange() ? 1 : 0 ) ) ->method( 'getPageviews' ) - ->willReturn( [ - 'items' => [ - [ 'views' => 1000 ], - [ 'views' => 500 ], - ], - ] ); + ->willReturn( 1500 ); + $this->page->expects( static::exactly( $this->pageInfo->hasDateRange() ? 0 : 1 ) ) + ->method( 'getLatestPageviews' ) + ->willReturn( 1500 ); static::assertEquals( [ 'count' => 1500, @@ -517,9 +724,12 @@ public function testPageviews(): void { } public function testPageviewsFailing(): void { - $this->pageRepo->expects( $this->once() ) + $this->page->expects( static::exactly( $this->pageInfo->hasDateRange() ? 1 : 0 ) ) ->method( 'getPageviews' ) - ->willThrowException( $this->createMock( BadGatewayException::class ) ); + ->willReturn( null ); + $this->page->expects( static::exactly( $this->pageInfo->hasDateRange() ? 0 : 1 ) ) + ->method( 'getLatestPageviews' ) + ->willReturn( null ); static::assertEquals( [ 'count' => null, diff --git a/tests/Model/PageTest.php b/tests/Model/PageTest.php index 42e451050..0e9b0f20c 100644 --- a/tests/Model/PageTest.php +++ b/tests/Model/PageTest.php @@ -12,6 +12,7 @@ use App\Repository\UserRepository; use App\Tests\TestAdapter; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; +use GuzzleHttp\Exception\ClientException; use Psr\Log\LoggerInterface; /** @@ -57,6 +58,30 @@ public function testTitles(): void { static::assertEquals( 'talk:Test Page_3', $page->getTitle( true ) ); } + /** + * A Page can be built through processing of a database row + */ + public function testNewFromRow(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $data = [ + [ [ 'page_title' => 'A', 'namespace' => 0 ], 'A' ], + [ [ 'page_title' => 'B', 'namespace' => 1, 'length' => 4, ], 'Talk:B' ], + ]; + $project = $this->createMock( Project::class ); + $project->expects( static::once() ) + ->method( 'getNamespaces' ) + ->willReturn( [ + 0 => '', + 1 => 'Talk', + ] ); + foreach ( $data as [ $row, $fullPageTitle ] ) { + $page = Page::newFromRow( $pageRepo, $project, $row ); + static::assertEquals( $row['namespace'], $page->getNamespace() ); + static::assertInstanceOf( Page::class, $page ); + static::assertEquals( $fullPageTitle, $page->getTitle( true ) ); + } + } + /** * A page either exists or doesn't. */ @@ -94,6 +119,9 @@ public function testBasicGetters(): void { 'Talk', 'User', ] ); + $project->expects( static::once() ) + ->method( 'getLang' ) + ->willReturn( 'en' ); $pageRepo = $this->createMock( PageRepository::class ); $pageRepo->expects( $this->once() ) @@ -111,14 +139,20 @@ public function testBasicGetters(): void { $page = new Page( $this->pageRepo, $project, 'User:Test:123' ); $page->setRepository( $pageRepo ); + // Should set the pageInfo + static::assertEquals( 2, $page->getNamespace() ); static::assertEquals( 42, $page->getId() ); static::assertEquals( 'https://example.org/User:Test:123', $page->getUrl() ); static::assertEquals( 5000, $page->getWatchers() ); static::assertEquals( 300, $page->getLength() ); - static::assertEquals( 2, $page->getNamespace() ); static::assertEquals( 'User', $page->getNamespaceName() ); static::assertEquals( 'Q95', $page->getWikidataId() ); static::assertEquals( 'Test:123', $page->getTitleWithoutNamespace() ); + // Intentionally defaults to project lang + static::assertEquals( 'en', $page->getLang() ); + // Ensure caching + static::assertEquals( 300, $page->getLength() ); + static::assertEquals( 2, $page->getNamespace() ); } /** @@ -134,6 +168,24 @@ public function testWikitext(): void { static::assertStringContainsString( '{{Main Page banner}}', $content ); } + /** + * Test fetching of HTML + */ + public function testHtml(): void { + $page = new Page( $this->pageRepo, $this->getMockEnwikiProject(), 'A' ); + // 01:01:01 UTC, January 1, 2001 + $date = new \DateTime( '20010101010101' ); + $this->pageRepo->expects( static::once() ) + ->method( 'getRevisionIdAtDate' ) + ->with( $page, $date ) + ->willReturn( 1234 ); + $this->pageRepo->expects( static::once() ) + ->method( 'getHTMLContent' ) + ->with( $page, 1234 ) + ->willReturn( 'Hello' ); + static::assertEquals( 'Hello', $page->getHTMLContent( $date ) ); + } + /** * Tests wikidata item getter. */ @@ -163,6 +215,8 @@ public function testWikidataItems(): void { $page->setRepository( $pageRepo ); static::assertArraySubset( $wikidataItems, $page->getWikidataItems() ); + // Ensure that we count the above + static::assertEquals( 2, $page->countWikidataItems() ); // If no wikidata item... $pageRepo2 = $this->createMock( PageRepository::class ); @@ -212,9 +266,43 @@ public function testUsersEdits(): void { $page = new Page( $this->pageRepo, new Project( 'exampleWiki' ), 'Page' ); $user = new User( $this->createMock( UserRepository::class ), 'Testuser' ); static::assertCount( 2, $page->getRevisions( $user ) ); + // Test caching + static::assertCount( 2, $page->getRevisions( $user ) ); static::assertEquals( 2, $page->getNumRevisions() ); } + /** + * Cases for counting of number of edits + */ + public function testNumRevisions(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->expects( static::exactly( 2 ) ) + ->method( 'getNumRevisions' ) + ->willReturn( 42 ); + $pageRepo->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( [] ); + $page = new Page( $pageRepo, new Project( 'examplewiki' ), 'Page' ); + + // #1: user given, so we query repo and don't cache + static::assertEquals( 42, $page->getNumRevisions( $this->createMock( User::class ) ) ); + + // #2: revisions happens to be set, so count that. doesn't query repo, but caches + static::assertEquals( [], $page->getRevisions() ); + static::assertSame( 0, $page->getNumRevisions() ); + // Test caching + static::assertSame( 0, $page->getNumRevisions() ); + + // #3: normal case, go out of our way to check + // reset caching + $page = new Page( $pageRepo, new Project( 'examplewiki' ), 'Page' ); + // queries repo for the second time + static::assertEquals( 42, $page->getNumRevisions() ); + + // #4: caching did work. If it doesn't we call the Repo method a third time + static::assertEquals( 42, $page->getNumRevisions() ); + } + /** * Test getErros and getCheckWikiErrors. */ @@ -270,10 +358,17 @@ public function testPageviews(): void { static::assertEquals( 3500, $page->getLatestPageviews( 30 ) ); // When the API fails. - $this->pageRepo->expects( $this->once() ) + $this->pageRepo->expects( static::once() ) ->method( 'getPageviews' ) ->willThrowException( $this->createMock( BadGatewayException::class ) ); static::assertNull( $page->getPageviews( '20230101', '20230131' ) ); + // 404, must return 0 + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->expects( static::once() ) + ->method( 'getPageviews' ) + ->willThrowException( $this->createMock( ClientException::class ) ); + $page = new Page( $pageRepo, new Project( 'exampleWiki' ), 'Page' ); + static::assertSame( 0, $page->getPageviews( '20230101', '20230131' ) ); } /** diff --git a/tests/Model/PagesTest.php b/tests/Model/PagesTest.php index deb39c5a6..fe4dae414 100644 --- a/tests/Model/PagesTest.php +++ b/tests/Model/PagesTest.php @@ -85,9 +85,9 @@ public function provideSummaryColumnsData(): array { public function testResults(): void { $this->setPagesResults(); - $pages = new Pages( $this->pagesRepo, $this->project, $this->user, 0, 'all' ); - $pages->setRepository( $this->pagesRepo ); - $pages->prepareData(); + $pages = new Pages( $this->pagesRepo, $this->project, $this->user, 'all', 'all' ); + // Ensure it does prepare + $pages->getResults(); static::assertEquals( 3, $pages->getNumResults() ); static::assertSame( 1, $pages->getNumDeleted() ); static::assertSame( 1, $pages->getNumRedirects() ); @@ -109,6 +109,7 @@ public function testResults(): void { ], ], $pages->getCounts() ); + // Also ensures it does cache $results = $pages->getResults(); static::assertEquals( [ 0, 1 ], array_keys( $results ) ); @@ -146,14 +147,109 @@ public function testResults(): void { 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/2/25/Symbol_a_class.svg', 'color' => '#66FFFF', 'category' => 'Category:A-Class articles', - 'projects' => [ 'Technology', 'Websites', 'Internet' ], + 'projects' => [ 'Technology', 'Internet' ], ], ], $results[1][0] ); static::assertTrue( $pages->isMultiNamespace() ); + static::assertNull( $pages->getLastTimestamp() ); + static::assertEquals( 27, $pages->getTotalPageSize() ); + static::assertEquals( 9, $pages->averagePageSize() ); + static::assertSame( 'all', $pages->getNamespace() ); + static::assertEquals( 50, $pages->resultsPerPage() ); + static::assertFalse( $pages->resultsPerPage( true ) ); + static::assertEquals( [ + [ 'pap_project_title' => 'Technology', 'count' => 2 ], + [ 'pap_project_title' => 'Random', 'count' => 1 ], + [ 'pap_project_title' => 'Computing', 'count' => 1 ], + [ 'pap_project_title' => 'Internet', 'count' => 1 ], + ], $pages->getWikiprojectCounts() ); + static::assertEquals( [ + 'A' => 1, + 'Unknown' => 1, + 'FA' => 1, + ], $pages->getAssessmentCounts() ); + } + + /** + * Make sure we just spit out what the repo says, + * when there are more than 1 pages of results. + */ + public function testPaManyPages(): void { + $project = $this->createMock( Project::class ); + $pagesRepo = $this->createMock( PagesRepository::class ); + $pagesRepo->expects( static::once() ) + ->method( 'countPagesCreated' ) + ->willReturn( [ [ + 'namespace' => 0, + 'count' => 5001, + 'total_length' => 0, + 'deleted' => 0, + 'redirects' => 0, + ] ] ); + $pages = new Pages( $pagesRepo, $project, $this->user, 'all', 'all' ); + static::assertEquals( 5001, $pages->getNumPages() ); + $value = [ 'Exactly the value given as input.' ]; + $pagesRepo->expects( static::once() ) + ->method( 'getWikiprojectCounts' ) + ->willReturn( $value ); + static::assertSame( $value, $pages->getWikiprojectCounts() ); + $pagesRepo->expects( static::once() ) + ->method( 'getAssessmentCounts' ) + ->willReturn( $value ); + static::assertSame( $value, $pages->getAssessmentCounts() ); + } + + public function testSingleNamespace(): void { + // Also does the ProofreadPage tests. + $project = $this->createMock( Project::class ); + $project->method( 'hasPageAssessments' ) + ->willReturn( false ); + $project->method( 'isPrpPage' ) + ->with( 104 ) + ->willReturn( true ); + $project->method( 'getNamespaces' ) + ->willReturn( [ 0 => 'Main', 1 => 'Talk', 104 => 'Page' ] ); + $pages = new Pages( $this->pagesRepo, $project, $this->user, 104, 'all' ); + $pagesRepo = $this->createMock( PagesRepository::class ); + $pagesRepo->expects( static::once() ) + ->method( 'getPagesCreated' ) + ->willReturn( [ [ + 'namespace' => 104, + 'type' => 'rev', + 'page_title' => 'AAA', + 'redirect' => '1', + 'rev_length' => 2, + 'length' => 20, + 'timestamp' => '20250101000000', + 'rev_id' => 42, + 'recreated' => null, + 'prp_quality' => 3, + 'was_redirect' => null, + ] ] ); + $pagesRepo->expects( static::once() ) + ->method( 'countPagesCreated' ) + ->willReturn( [ [ + 'namespace' => 104, + 'count' => 1, + 'deleted' => 0, + 'redirects' => 1, + 'total_length' => 20, + 'prp_quality0' => 0, + 'prp_quality1' => 0, + 'prp_quality2' => 0, + 'prp_quality3' => 1, + 'prp_quality4' => 0, + ] ] ); + $pages->setRepository( $pagesRepo ); + static::assertFalse( $pages->isMultiNamespace() ); + static::assertEquals( '2025-01-01T00:00:00Z', $pages->getLastTimestamp() ); + static::assertSame( 1, $pages->getNumPages() ); + $counts = $pages->getCounts(); + static::assertSame( 1, $counts[104]['prp_quality3'] ); } public function setPagesResults(): void { - $this->pagesRepo->expects( $this->exactly( 2 ) ) + $this->pagesRepo->expects( static::exactly( 2 ) ) ->method( 'getPagesCreated' ) ->willReturn( [ [ @@ -168,7 +264,7 @@ public function setPagesResults(): void { 'recreated' => null, 'pa_class' => 'A', 'was_redirect' => null, - 'pap_project_title' => '["Technology","Websites","Internet"]', + 'pap_project_title' => '["Technology","Internet"]', ], [ 'namespace' => 0, 'type' => 'arc', @@ -194,7 +290,7 @@ public function setPagesResults(): void { 'recreated' => null, 'pa_class' => 'FA', 'was_redirect' => null, - 'pap_project_title' => '["Computing","Technology","Linguistics"]', + 'pap_project_title' => '["Computing","Technology"]', ], ] ); $this->pagesRepo->expects( $this->once() ) @@ -216,24 +312,55 @@ public function setPagesResults(): void { ] ); } - public function testDeletionSummary(): void { + /** + * Users get tooltips containing deletion summaries, for deleted pages. + * @dataProvider deletionSummaryProvider + * @param array|null $data + * @param int $ns + * @param string $title + * @param string $offset + * @param string|null $result + */ + public function testDeletionSummary( + ?array $data, + int $ns, + string $title, + string $offset, + ?string $result + ): void { $project = new Project( 'testWiki' ); $project->setRepository( $this->getProjectRepo() ); $this->pagesRepo->expects( static::once() ) ->method( 'getDeletionSummary' ) - ->willReturn( [ - 'actor_name' => 'MusikAnimal', - 'comment_text' => '[[WP:AfD|Articles for deletion]]', - 'log_timestamp' => '20210108224022', - ] ); + ->willReturn( $data ); $pages = new Pages( $this->pagesRepo, $project, $this->user ); $pages->setRepository( $this->pagesRepo ); static::assertEquals( + $result, + $pages->getDeletionSummary( $ns, $title, $offset ) + ); + } + + public function deletionSummaryProvider(): array { + return [ [ + [ + 'actor_name' => 'MusikAnimal', + 'comment_text' => '[[WP:AfD|Articles for deletion]]', + 'log_timestamp' => '20210108224022', + ], + 0, + 'Foobar', + '20210108224000', "2021-01-08 22:40 (" . "MusikAnimal): " . "Articles for deletion", - $pages->getDeletionSummary( 0, 'Foobar', '20210108224000' ) - ); + ], [ + [], + 0, + 'Foobar', + '202101082240000', + null, + ] ]; } /** diff --git a/tests/Model/ProjectTest.php b/tests/Model/ProjectTest.php index afe1d3743..7a050f540 100644 --- a/tests/Model/ProjectTest.php +++ b/tests/Model/ProjectTest.php @@ -4,8 +4,11 @@ namespace App\Tests\Model; +use App\Model\Page; +use App\Model\PageAssessments; use App\Model\Project; use App\Model\User; +use App\Repository\PageRepository; use App\Repository\ProjectRepository; use App\Repository\UserRepository; use App\Tests\TestAdapter; @@ -35,20 +38,42 @@ public function testBasicMetadata(): void { 'general' => [ 'articlePath' => '/test_wiki/$1', 'scriptPath' => '/test_w', + 'wikiName' => 'Test Wiki', + 'mainpage' => 'Test Main Page', ], ] ); + $this->projectRepo->expects( static::once() ) + ->method( 'getApiPath' ) + ->willReturn( '/w/api.php' ); $project = new Project( 'testWiki' ); $project->setRepository( $this->projectRepo ); static::assertEquals( 'test.example.org', $project->getDomain() ); static::assertEquals( 'test_wiki', $project->getDatabaseName() ); + static::assertEquals( 'test_wiki', $project->getCacheKey() ); static::assertEquals( 'https://test.example.org/', $project->getUrl() ); static::assertEquals( 'en', $project->getLang() ); static::assertEquals( '/test_w', $project->getScriptPath() ); static::assertEquals( '/test_wiki/$1', $project->getArticlePath() ); + static::assertEquals( 'https://test.example.org/w/api.php', $project->getApiUrl() ); + static::assertEquals( 'Test Wiki (test.example.org)', $project->getTitle() ); + static::assertEquals( 'Test Main Page', $project->getMainPage() ); static::assertTrue( $project->exists() ); } + /** + * Test fallback behaviour when URL not found + */ + public function testMetadataNoUrl(): void { + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( static::once() ) + ->method( 'getOne' ) + ->willReturn( [] ); + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertSame( '', $project->getMainPage() ); + } + /** * A project has a set of namespaces, comprising integer IDs and string titles. */ @@ -68,6 +93,47 @@ public function testNamespaces(): void { static::assertEquals( 'Main', $project->getNamespaces()[0] ); } + /** + * Each namespace has a language-independent canonical name. + */ + public function testCanonicalNamespaces(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ + 'canonical_namespaces' => [ 0 => '', 1 => 'Talk', 104 => 'Page' ], + ] ); + $projectRepo->expects( static::once() ) + ->method( 'getInstalledExtensions' ) + ->willReturn( [ 'ProofreadPage' ] ); + + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertTrue( $project->isPrpPage( 104 ) ); + + // Tests that getMetadata was in fact called only once and cached afterwards + static::assertSame( '', $project->getCanonicalNamespace( 0 ) ); + + // Ensure we default to '' when not found + static::assertSame( '', $project->getCanonicalNamespace( -1 ) ); + } + + /** + * A project has a list of installed extensions + */ + public function testExtensions(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getInstalledExtensions' ) + ->willReturn( [ 'NoThing', 'ProofreadPage' ] ); + + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertTrue( $project->hasProofreadPage() ); + static::assertFalse( $project->hasVisualEditor() ); + static::assertFalse( $project->hasPageTriage() ); + } + /** * XTools can be run in single-wiki mode, where there is only one project. */ @@ -131,40 +197,95 @@ public function testGetScript(): void { static::assertEquals( '/w/index.php', $project2->getScript() ); } + /** + * Projects can have varying temporary account config. + */ + public function testTempAccounts(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ + 'tempAccountPatterns' => [ + "*$1", + "~2$1", + ], + ] ); + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertTrue( $project->hasTempAccounts() ); + static::assertEquals( [ "*$1", "~2$1" ], $project->getTempAccountPatterns() ); + } + + /** + * A project may use PageAssessments in specific namespaces + */ + public function testPageAssessments(): void { + $pa = $this->createMock( PageAssessments::class ); + $pa->expects( static::once() ) + ->method( 'isSupportedNamespace' ) + ->willReturn( false ); + $pa->expects( static::once() ) + ->method( 'isEnabled' ) + ->willReturn( true ); + $project = new Project( 'testWiki' ); + $project->setPageAssessments( $pa ); + static::assertEquals( $project->getPageAssessments(), $pa ); + static::assertTrue( $project->hasPageAssessments() ); + static::assertFalse( $project->hasPageAssessments( 42 ) ); + } + /** * A user or a whole project can opt in to displaying restricted statistics. * @dataProvider optedInProvider * @param string[] $optedInProjects List of projects. * @param string $dbName The database name. * @param string $domain The domain name. + * @param \stdClass|null $ident Identification information. + * @param bool $localExists + * @param bool $globalExists * @param bool $hasOptedIn The result to check against. */ - public function testOptedIn( array $optedInProjects, string $dbName, string $domain, bool $hasOptedIn ): void { + public function testOptedIn( + array $optedInProjects, + string $dbName, + string $domain, + ?\stdClass $ident, + bool $localExists, + bool $globalExists, + bool $hasOptedIn + ): void { $project = new Project( $dbName ); $globalProject = new Project( 'metawiki' ); - - /** @var ProjectRepository|MockObject $globalProjectRepo */ $globalProjectRepo = $this->createMock( ProjectRepository::class ); - - $this->projectRepo->expects( static::once() ) + $globalProjectRepo->expects( static::any() ) + ->method( 'pageHasContent' ) + ->with( $globalProject, 2, 'TestUser/EditCounterGlobalOptIn.js' ) + ->willReturn( $globalExists ); + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( static::once() ) ->method( 'optedIn' ) ->willReturn( $optedInProjects ); - $this->projectRepo->expects( static::once() ) + $projectRepo->expects( static::once() ) ->method( 'getOne' ) ->willReturn( [ 'dbName' => $dbName, 'domain' => "https://$domain.org", ] ); - $this->projectRepo->method( 'getGlobalProject' ) + $projectRepo->method( 'getGlobalProject' ) ->willReturn( $globalProject ); - $this->projectRepo->method( 'pageHasContent' ) + $projectRepo->method( 'pageHasContent' ) ->with( $project, 2, 'TestUser/EditCounterOptIn.js' ) - ->willReturn( $hasOptedIn ); - $project->setRepository( $this->projectRepo ); + ->willReturn( $localExists ); + $project->setRepository( $projectRepo ); $globalProject->setRepository( $globalProjectRepo ); // Check that the user has opted in or not. - $user = new User( $this->userRepo, 'TestUser' ); + $userRepo = $this->createMock( UserRepository::class ); + $userRepo->expects( static::any() ) + ->method( 'getXtoolsUserInfo' ) + ->willReturn( $ident ); + static::assertEquals( $ident, $userRepo->getXtoolsUserInfo() ); + $user = new User( $userRepo, 'TestUser' ); static::assertEquals( $hasOptedIn, $project->userHasOptedIn( $user ) ); } @@ -175,9 +296,20 @@ public function testOptedIn( array $optedInProjects, string $dbName, string $dom public function optedInProvider(): array { $optedInProjects = [ 'project1' ]; return [ - [ $optedInProjects, 'project1', 'test.example.org', true ], - [ $optedInProjects, 'project2', 'test2.example.org', false ], - [ $optedInProjects, 'project3', 'test3.example.org', false ], + [ $optedInProjects, 'project1', 'test.example.org', null, false, false, true ], + [ $optedInProjects, 'project2', 'test2.example.org', null, false, false, false ], + [ $optedInProjects, 'project3', 'test3.example.org', null, false, false, false ], + [ + $optedInProjects, + 'project4', + 'test4.example.org', + (object)[ 'username' => 'TestUser' ], + false, + false, + true + ], + [ $optedInProjects, 'project5', 'test5.example.org', null, true, false, true ], + [ $optedInProjects, 'project6', 'test6.example.org', null, false, true, true ], ]; } @@ -227,12 +359,18 @@ public function testUsersInGroups(): void { public function testGetUrlForPage(): void { $projectRepo = $this->getProjectRepo(); - $projectRepo->expects( static::once() )->method( 'getMetadata' ); + $projectRepo->expects( static::exactly( 2 ) )->method( 'getMetadata' ); $project = new Project( 'testWiki' ); $project->setRepository( $projectRepo ); static::assertEquals( "https://test.example.org/wiki/Foobar", $project->getUrlForPage( 'Foobar' ) ); + $pageRepo = $this->createMock( PageRepository::class ); + $page = new Page( $pageRepo, $project, 'Foobar' ); + static::assertEquals( + "https://test.example.org/wiki/Foobar", + $project->getUrlForPage( $page ) + ); } } diff --git a/tests/Model/TopEditsTest.php b/tests/Model/TopEditsTest.php index f93239025..2f92240f6 100644 --- a/tests/Model/TopEditsTest.php +++ b/tests/Model/TopEditsTest.php @@ -84,6 +84,10 @@ public function testBasic(): void { $page = new Page( $this->pageRepo, $this->project, 'Test page' ); $te->setPage( $page ); static::assertEquals( $page, $te->getPage() ); + + // Explicit pagination + $te = $this->getTopEdits( null, 'all', false, false, 20, 1 ); + static::assertSame( 1, $te->getPagination() ); } /** @@ -115,6 +119,7 @@ public function testTopEditsAllNamespaces(): void { 'assessment' => [ 'class' => 'List', ], + 'pap_project_title' => '["Biography","India"]', ], $result[0][0] ); // Fetching again should use value of class property. @@ -127,25 +132,49 @@ public function testTopEditsAllNamespaces(): void { * Getting top edited pages within a single namespace. */ public function testTopEditsNamespace(): void { - $te = $this->getTopEdits( null, 3, false, false, 2 ); - $this->teRepo->expects( $this->once() ) + $te = $this->getTopEdits( null, 0, false, false, 2 ); + $this->teRepo->expects( static::once() ) ->method( 'getTopEditsNamespace' ) - ->with( $this->project, $this->user, 3, false, false, 2 ) - ->willReturn( $this->topEditsNamespaceFactory()[3] ); + ->with( $this->project, $this->user, 0, false, false, 2 ) + ->willReturn( $this->topEditsNamespaceFactory()[0] ); + $this->teRepo->expects( static::once() ) + ->method( 'countEdits' ) + ->willReturn( 42 ); $te->setRepository( $this->teRepo ); $te->prepareData(); $result = $te->getTopEdits(); - static::assertEquals( [ 3 ], array_keys( $result ) ); + static::assertEquals( 42, $te->getNumTopEdits() ); + static::assertEquals( [ 0 ], array_keys( $result ) ); static::assertCount( 1, $result ); - static::assertCount( 2, $result[3] ); + static::assertCount( 2, $result[0] ); static::assertEquals( [ - 'namespace' => '3', - 'page_title' => 'Jimbo Wales', + 'namespace' => '0', + 'page_title' => '101st Airborne Division', 'redirect' => '0', - 'count' => '1', - 'full_page_title' => 'User talk:Jimbo Wales', - ], $result[3][1] ); + 'count' => '18', + 'full_page_title' => '101st Airborne Division', + 'pap_project_title' => null, + 'assessment' => [ 'class' => 'C' ], + ], $result[0][1] ); + } + + /** + * Ensure we do not show any data if the user has not opted in. + */ + public function testNotOptedIn(): void { + $project = $this->createMock( Project::class ); + $project->expects( static::once() ) + ->method( 'userHasOptedIn' ) + ->willReturn( false ); + $te = new TopEdits( + $this->teRepo, + $this->autoEditsHelper, + $project, + $this->user + ); + $te->prepareData(); + static::assertCount( 0, $te->getTopEdits() ); } /** @@ -162,6 +191,10 @@ private function topEditsNamespaceFactory(): array { 'count' => '24', 'pa_class' => 'List', 'full_page_title' => 'Foo_bar', + 'pap_project_title' => json_encode( [ + 'Biography', + 'India', + ] ), ], [ 'namespace' => '0', 'page_title' => '101st_Airborne_Division', @@ -169,6 +202,7 @@ private function topEditsNamespaceFactory(): array { 'count' => '18', 'pa_class' => 'C', 'full_page_title' => '101st_Airborne_Division', + 'pap_project_title' => null, ], ], 3 => [ @@ -276,6 +310,7 @@ private function topEditsPageFactory(): array { * @param int|false $start Start date as Unix timestamp. * @param int|false $end End date as Unix timestamp. * @param int|null $limit Number of rows to fetch. + * @param int $pagination = 0 * @return TopEdits */ private function getTopEdits( @@ -283,7 +318,8 @@ private function getTopEdits( $namespace = 0, $start = false, $end = false, - ?int $limit = null + ?int $limit = null, + int $pagination = 0 ): TopEdits { return new TopEdits( $this->teRepo, @@ -294,7 +330,8 @@ private function getTopEdits( $namespace, $start, $end, - $limit + $limit, + $pagination ); } } diff --git a/tests/Model/UserRightsTest.php b/tests/Model/UserRightsTest.php index 068b57128..d75f0ffb4 100644 --- a/tests/Model/UserRightsTest.php +++ b/tests/Model/UserRightsTest.php @@ -22,9 +22,14 @@ class UserRightsTest extends TestAdapter { protected UserRights $userRights; protected UserRightsRepository $userRightsRepo; protected UserRepository $userRepo; + protected Project $project; public function setUp(): void { - $this->i18n = static::createClient()->getContainer()->get( 'app.i18n_helper' ); + $this->i18n = $this->createMock( I18nHelper::class ); + $this->i18n->expects( static::any() ) + ->method( 'getLang' ) + ->willReturn( 'en' ); + // static::createClient()->getContainer()->get('app.i18n_helper'); $project = new Project( 'test.example.org' ); $projectRepo = $this->getProjectRepo(); $projectRepo->method( 'getMetadata' ) @@ -32,6 +37,7 @@ public function setUp(): void { 'tempAccountPatterns' => [ '~2$1' ], ] ); $project->setRepository( $projectRepo ); + $this->project = $project; $this->userRepo = $this->createMock( UserRepository::class ); $this->user = new User( $this->userRepo, 'Testuser' ); $this->userRightsRepo = $this->createMock( UserRightsRepository::class ); @@ -126,6 +132,19 @@ public function testUserRightsChanges(): void { 'log_deleted' => '2', ], ] ); + $this->userRightsRepo->expects( static::once() ) + ->method( 'getAutoConfirmedAgeAndCount' ) + ->willReturn( [ + // 1 second, 1 edit + 'wgAutoConfirmAge' => 1, + 'wgAutoConfirmCount' => 1, + ] ); + $this->userRightsRepo->expects( static::once() ) + ->method( 'getNumEditsByTimestamp' ) + ->willReturn( 2 ); + $this->userRightsRepo->expects( static::once() ) + ->method( 'getRightsNames' ) + ->willReturn( [ 'sysop' => 'Administrator' ] ); /** @var MockObject|UserRepository $userRepo */ $userRepo = $this->createMock( UserRepository::class ); @@ -136,7 +155,20 @@ public function testUserRightsChanges(): void { ] ); $this->user->setRepository( $userRepo ); + static::assertEquals( 20180101000001, $this->userRights->getAutoConfirmedTimeStamp() ); static::assertEquals( [ + 20180101000001 => [ + 'logId' => null, + 'performer' => null, + 'comment' => null, + 'added' => [ 'autoconfirmed' ], + 'removed' => [], + 'grantType' => 'automatic', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], 20181025000000 => [ 'logId' => '92769185', 'performer' => 'Worm That Turned', @@ -273,9 +305,15 @@ public function testUserRightsChanges(): void { ->willReturn( [ 'sysop' ] ); $this->user->setRepository( $userRepo ); + // Global rights and changes. + static::assertEquals( [ + 'current' => [ 'sysop' ], + 'former' => [], + ], $this->userRights->getGlobalRightsStates() ); + // Current rights. static::assertEquals( - [ 'sysop', 'bureaucrat' ], + [ 'sysop', 'bureaucrat', 'autoconfirmed' ], $this->userRights->getRightsStates()['local']['current'] ); @@ -285,7 +323,391 @@ public function testUserRightsChanges(): void { $this->userRights->getRightsStates()['local']['former'] ); - // Admin status. - static::assertEquals( 'current', $this->userRights->getAdminStatus() ); + // Rights names. + static::assertEquals( 'Administrator', $this->userRights->getRightsName( 'sysop' ) ); + // Missing key, and ensure caching. + static::assertEquals( 'example', $this->userRights->getRightsName( 'example' ) ); + } + + /** + * Test various edge cases and unexpected incidents during log processsing + * @dataProvider edgeCaseProvider + * @param array $logData + * @param bool $hasImpossibleLogs + * @param array $result + */ + public function testChangesEdgeCases( + array $logData, + bool $hasImpossibleLogs, + array $result + ): void { + $this->userRightsRepo->expects( static::once() ) + ->method( 'getRightsChanges' ) + ->willReturn( $logData ); + static::assertEquals( $result, $this->userRights->getRightsChanges() ); + static::assertEquals( $hasImpossibleLogs, $this->userRights->hasImpossibleLogs() ); + } + + public function edgeCaseProvider(): array { + return [ [ + // Dataset #0: Expiry modification + [ + [ + // Temporary intadmin grant until timestamp 4 + 'log_id' => '1234', + 'log_timestamp' => '0', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:0:{}s:12:"5::newgroups";a:1:{i:0;s:15:' . + '"interface-admin";}s:11:"oldmetadata";a:0:{}s:11:"newmetadata";a:1:{i:0;a:1:' . + '{s:6:"expiry";s:1:"8";}}}', + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => 'One', + 'type' => 'local', + 'log_deleted' => 0, + ], + [ + // One second before, extended until 8 + 'log_id' => '5678', + 'log_timestamp' => '3', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:1:{i:0;s:15:"interface-admin";}s:12:"5::newgroups";' . + 'a:1:{i:0;s:15:"interface-admin";}s:11:"oldmetadata";a:1:{i:0;a:1:{s:6:"expiry";s:1:"4";}}s:' . + '11:"newmetadata";a:1:{i:0;a:1:{s:6:"expiry";s:1:"8";}}}', + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => 'Two!', + 'type' => 'local', + 'log_deleted' => 0, + ], + ], + false, + [ + 0 => [ + 'logId' => '1234', + 'performer' => 'Random', + 'comment' => 'One', + 'added' => [ 'interface-admin' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 3 => [ + 'logId' => '5678', + 'performer' => 'Random', + 'comment' => 'Two!', + 'added' => [ 'interface-admin' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 8 => [ + 'logId' => '5678', + 'performer' => 'Random', + 'comment' => null, + 'added' => [], + 'removed' => [ 'interface-admin' ], + 'grantType' => 'automatic', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + ], + ], [ + // Dataset #1: Impossible logs + [ + [ + // removal of sysop + 'log_id' => '1234', + 'log_timestamp' => '0', + 'log_params' => "sysop\n", + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => '...', + 'type' => 'local', + 'log_deleted' => 0, + ], + ], + true, + [ + [ + 'logId' => '1234', + 'performer' => 'Random', + 'comment' => '...', + 'added' => [], + 'removed' => [ 'sysop' ], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + ], + ], [ + // Dataset #2: everything revdeleted + [ + [ + // removal of sysop + 'log_id' => '1234', + 'log_timestamp' => '0', + 'log_params' => null, + 'log_action' => 'rights', + 'performer' => null, + 'log_comment' => null, + 'type' => 'local', + 'log_deleted' => 7, + ], + ], + false, + [ + 0 => [ + 'logId' => '1234', + 'performer' => null, + 'comment' => null, + 'added' => [], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => true, + 'commentDeleted' => true, + 'performerDeleted' => true, + ], + ], + ], [ + // Dataset #3: (none)s to splice out + [ + [ + // none in old + 'log_id' => '1234', + 'log_timestamp' => '0', + 'log_params' => "(none)\nsysop", + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => '...', + 'type' => 'local', + 'log_deleted' => 0, + ], + [ + // none in new + 'log_id' => '5678', + 'log_timestamp' => '1', + 'log_params' => "sysop\n(none)", + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => '...', + 'type' => 'local', + 'log_deleted' => 0, + ], + ], + false, + [ + 0 => [ + 'logId' => '1234', + 'performer' => 'Random', + 'comment' => '...', + 'added' => [ 'sysop' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 1 => [ + 'logId' => '5678', + 'performer' => 'Random', + 'comment' => '...', + 'added' => [], + 'removed' => [ 'sysop' ], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + ], + ], [ + // Dataset #4: removing pending auto removals on manual removal + [ + [ + // Temporary intadmin grant until timestamp 4 + 'log_id' => '1234', + 'log_timestamp' => '0', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:0:{}s:12:"5::newgroups";a:1:{i:0;s:15:' . + '"interface-admin";}s:11:"oldmetadata";a:0:{}s:11:"newmetadata";a:1:{i:0;a:1:' . + '{s:6:"expiry";s:1:"8";}}}', + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => 'One', + 'type' => 'local', + 'log_deleted' => 0, + ], + [ + // One second before, removed manually + 'log_id' => '5678', + 'log_timestamp' => '3', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:1:{i:0;s:15:"interface-admin";}s:12:"5::newgroups";' . + 'a:0:{}s:11:"oldmetadata";a:1:{i:0;a:1:{s:6:"expiry";s:1:"4";}}s:11:"newmetadata";a:0:{}}', + 'log_action' => 'rights', + 'performer' => 'Random', + 'log_comment' => 'Two!', + 'type' => 'local', + 'log_deleted' => 0, + ], + ], + false, + [ + 0 => [ + 'logId' => '1234', + 'performer' => 'Random', + 'comment' => 'One', + 'added' => [ 'interface-admin' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 3 => [ + 'logId' => '5678', + 'performer' => 'Random', + 'comment' => 'Two!', + 'added' => [], + 'removed' => [ 'interface-admin' ], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + ], + ] ]; + } + + /** + * Admin status + * @dataProvider adminStatusProvider + * @param array $currentRights + * @param array $rightsChanges + * @param string|bool $adminStatus + */ + public function testAdminStatus( + array $currentRights, + array $rightsChanges, + $adminStatus + ): void { + $user = $this->createMock( User::class ); + $user->expects( static::once() ) + ->method( 'getUserRights' ) + ->willReturn( $currentRights ); + $this->userRightsRepo->expects( static::once() ) + ->method( 'getRightsChanges' ) + ->willReturn( $rightsChanges ); + $userRights = new UserRights( $this->userRightsRepo, $this->project, $user, $this->i18n ); + static::assertEquals( $adminStatus, $userRights->getAdminStatus() ); + } + + public function adminStatusProvider(): array { + return [ + [ + [ 'sysop' ], + [], + 'current', + ], + [ + [], + [ + [ + 'log_timestamp' => 0, + 'log_action' => '', + 'log_id' => 3, + 'log_params' => "\nsysop", + 'log_comment' => null, + 'performer' => 'Ghost', + 'type' => '', + 'log_deleted' => 2, + ], + [ + 'log_timestamp' => 1, + 'log_action' => '', + 'log_id' => 4, + 'log_params' => "sysop\n", + 'log_comment' => null, + 'performer' => 'Ghost', + 'type' => '', + 'log_deleted' => 2, + ], + ], + 'former', + ], + [ + [], + [], + false, + ], + ]; + } + + /** + * Test autoconfirmed calculations + * @dataProvider autoconfirmedTimestampProvider + * @param bool $isTemp + * @param array|null $thresholds + * @param \DateTime|null $regDate + * @param int $editsByAcDate + * @param \DateTime|false $nthEditTimestamp + * @param \DateTime|false $resTimestamp + */ + public function testAutoconfirmedTimestamp( + bool $isTemp, + ?array $thresholds, + $regDate, + int $editsByAcDate, + $nthEditTimestamp, + $resTimestamp + ): void { + $user = $this->createMock( User::class ); + $user->expects( static::once() ) + ->method( 'isTemp' ) + ->willReturn( $isTemp ); + $this->userRightsRepo->expects( static::any() ) + ->method( 'getAutoconfirmedAgeAndCount' ) + ->willReturn( $thresholds ); + $user->expects( static::any() ) + ->method( 'getRegistrationDate' ) + ->willReturn( $regDate ); + $this->userRightsRepo->expects( static::any() ) + ->method( 'getNumEditsByTimestamp' ) + ->willReturn( $editsByAcDate ); + $this->userRightsRepo->expects( static::any() ) + ->method( 'getNthEditTimestamp' ) + ->willReturn( $nthEditTimestamp ); + $userRights = new UserRights( $this->userRightsRepo, $this->project, $user, $this->i18n ); + static::assertEquals( $resTimestamp, $userRights->getAutoconfirmedTimestamp() ); + } + + public function autoconfirmedTimestampProvider(): array { + $stamp = static fn ( $s ) => strval( 20250101000000 + $s ); + $time = static fn ( $s ) => new \DateTime( $stamp( $s ) ); + return [ + // Dataset #0: temporary used + [ true, null, null, 3, $time( 2 ), false ], + // Dataset #1: null thresholds + [ false, null, null, 3, $time( 2 ), false ], + // Dataset #2: null registration date + [ false, [ 'wgAutoConfirmAge' => 4, 'wgAutoConfirmCount' => 2 ], null, 3, $time( 2 ), false ], + // Dataset #3: got the required edits before required age + [ false, [ 'wgAutoConfirmAge' => 4, 'wgAutoConfirmCount' => 2 ], $time( 0 ), 3, $time( 2 ), $stamp( 4 ) ], + // Dataset #4: got the required edits after required age + [ false, [ 'wgAutoConfirmAge' => 4, 'wgAutoConfirmCount' => 2 ], $time( 0 ), 1, $time( 6 ), $stamp( 6 ) ], + // Dataset #5: never got the required edits + [ false, [ 'wgAutoConfirmAge' => 4, 'wgAutoConfirmCount' => 2 ], $time( 0 ), 1, false, false ], + ]; } } diff --git a/tests/Model/UserTest.php b/tests/Model/UserTest.php index f05c4cf83..9da70b9de 100644 --- a/tests/Model/UserTest.php +++ b/tests/Model/UserTest.php @@ -10,6 +10,9 @@ use App\Repository\UserRepository; use App\Tests\TestAdapter; use DateTime; +use Exception; +use PHPUnit\Framework\MockObject\Stub\Stub; +use UnexpectedValueException; /** * Tests for the User class. @@ -28,6 +31,7 @@ public function setUp(): void { public function testUsernameHasInitialCapital(): void { $user = new User( $this->userRepo, 'lowercasename' ); static::assertEquals( 'Lowercasename', $user->getUsername() ); + static::assertEquals( md5( 'Lowercasename' ), $user->getCacheKey() ); $user2 = new User( $this->userRepo, 'UPPERCASENAME' ); static::assertEquals( 'UPPERCASENAME', $user2->getUsername() ); } @@ -38,7 +42,7 @@ public function testUsernameHasInitialCapital(): void { */ public function testUserHasIdOnProject(): void { // Set up stub user and project repositories. - $this->userRepo->expects( $this->once() ) + $this->userRepo->expects( $this->exactly( 2 ) ) ->method( 'getIdAndRegistration' ) ->willReturn( [ 'userId' => 12, @@ -54,6 +58,7 @@ public function testUserHasIdOnProject(): void { $project = new Project( 'wiki.example.org' ); $project->setRepository( $projectRepo ); static::assertEquals( 12, $user->getId( $project ) ); + static::assertTrue( $user->existsOnProject( $project ) ); } /** @@ -138,7 +143,7 @@ public function testRegistrationDate(): void { * System edit count. */ public function testEditCount(): void { - $this->userRepo->expects( $this->once() ) + $this->userRepo->expects( static::once() ) ->method( 'getEditCount' ) ->willReturn( 12345 ); $user = new User( $this->userRepo, 'TestUser' ); @@ -166,6 +171,9 @@ public function testHasTooManyEdits(): void { $this->userRepo->expects( $this->exactly( 3 ) ) ->method( 'maxEdits' ) ->willReturn( 250000 ); + $this->userRepo->expects( static::once() ) + ->method( 'numEditsRequiringLogin' ) + ->willReturn( 12344 ); $user = new User( $this->userRepo, 'TestUser' ); $projectRepo = $this->createMock( ProjectRepository::class ); @@ -180,6 +188,9 @@ public function testHasTooManyEdits(): void { // User::tooManyEdits() static::assertTrue( $user->hasTooManyEdits( $project ) ); + + // User:hasManyEdits() + static::assertTrue( $user->hasManyEdits( $project ) ); } /** @@ -191,6 +202,8 @@ public function testIpMethods(): void { static::assertFalse( $user->isIpRange() ); static::assertFalse( $user->isIPv6() ); static::assertEquals( '192.168.0.0', $user->getUsernameIdent() ); + // should not call any Project methods + static::assertTrue( $user->isAnon( $this->createMock( Project::class ) ) ); $user = new User( $this->userRepo, '74.24.52.13/20' ); static::assertTrue( $user->isIP() ); @@ -249,16 +262,29 @@ public function testIsQueryableRange(): void { /** * From Core's PatternTest https://w.wiki/BZQH (GPL-2.0-or-later) * @dataProvider provideIsTempUsername + * @param bool $hasTemp * @param string $stringPattern * @param string $name * @param bool $expected * @return void */ - public function testIsTemp( string $stringPattern, string $name, bool $expected ): void { + public function testIsTemp( bool $hasTemp, string $stringPattern, string $name, bool $expected ): void { $project = $this->createMock( Project::class ); - $project->method( 'hasTempAccounts' )->willReturn( true ); - $project->method( 'getTempAccountPatterns' )->willReturn( [ $stringPattern ] ); - static::assertSame( $expected, User::isTempUsername( $project, $name ) ); + $project->expects( static::once() ) + ->method( 'hasTempAccounts' ) + ->willReturn( $hasTemp ); + $project->expects( $hasTemp ? static::once() : static::never() ) + ->method( 'getTempAccountPatterns' ) + ->willReturn( [ $stringPattern ] ); + $user = new User( $this->userRepo, $name ); + try { + static::assertSame( $expected, $user->isTemp( $project ) ); + // Check that if the pattern is invalid we errored + static::assertStringContainsString( '$1', $stringPattern ); + } catch ( UnexpectedValueException $e ) { + // Check that we get here only if the pattern is invalid + static::assertStringNotContainsString( '$1', $stringPattern ); + } } /** @@ -267,45 +293,100 @@ public function testIsTemp( string $stringPattern, string $name, bool $expected public static function provideIsTempUsername(): array { return [ 'prefix mismatch' => [ + 'hasTemp' => true, 'pattern' => '*$1', 'name' => 'Test', 'expected' => false, ], 'prefix match' => [ + 'hasTemp' => true, 'pattern' => '*$1', 'name' => '*Some user', 'expected' => true, ], 'suffix only match' => [ + 'hasTemp' => true, 'pattern' => '$1*', 'name' => 'Some user*', 'expected' => true, ], 'suffix only mismatch' => [ + 'hasTemp' => true, 'pattern' => '$1*', 'name' => 'Some user', 'expected' => false, ], 'prefix and suffix match' => [ + 'hasTemp' => true, 'pattern' => '*$1*', 'name' => '*Unregistered 123*', 'expected' => true, ], 'prefix and suffix mismatch' => [ + 'hasTemp' => true, 'pattern' => '*$1*', 'name' => 'Unregistered 123*', 'expected' => false, ], 'prefix and suffix zero length match' => [ + 'hasTemp' => true, 'pattern' => '*$1*', 'name' => '**', 'expected' => true, ], 'prefix and suffix overlapping' => [ + 'hasTemp' => true, 'pattern' => '*$1*', 'name' => '*', 'expected' => false, ], + 'no temp accounts' => [ + 'hasTemp' => false, + 'pattern' => '*$1*', + 'name' => '**', + 'expected' => false, + ], + 'invalid pattern' => [ + 'hasTemp' => true, + 'pattern' => '', + 'nam' => '*', + 'expected' => false, + ], + ]; + } + + /** + * Test identification logic + * @dataProvider isCurrentlyLoggedInProvider + * @param Stub $userInfo + * @param bool $expected + */ + public function testIsCurrentlyLoggedIn( Stub $userInfo, bool $expected ): void { + $this->userRepo->expects( static::once() ) + ->method( 'getXtoolsUserInfo' ) + ->will( $userInfo ); + $user = new User( $this->userRepo, 'Foo' ); + static::assertEquals( $expected, $user->isCurrentlyLoggedIn() ); + } + + public function isCurrentlyLoggedInProvider(): array { + return [ + 'not_logged_in' => [ + $this->throwException( new Exception( '' ) ), + false, + ], + 'malformed_ident' => [ + $this->returnValue( (object)[] ), + false, + ], + 'wrong_user' => [ + $this->returnValue( (object)[ 'username' => 'Bar' ] ), + false, + ], + 'right_user' => [ + $this->returnValue( (object)[ 'username' => 'Foo' ] ), + true, + ], ]; } }